hobokai 님의 블로그

Spring Boot 완전 가이드 Part 4: 데이터 액세스와 배치 본문

Backend

Spring Boot 완전 가이드 Part 4: 데이터 액세스와 배치

hobokai 2025. 8. 6. 10:37

시리즈 소개: 실무에서 바로 써먹는 Spring Boot 완전 가이드

Part 1: Spring Boot 기초 - 시작하기
Part 2: 설정과 자동구성
Part 3: 웹 개발과 REST API
Part 4: 데이터 액세스와 배치 ← 현재
Part 5: 운영과 모니터링


💾 Spring Data JPA 실전 활용

Entity 설계 Best Practices

기본 Entity 구조

@Entity
@Table(name = "users", 
       indexes = {
           @Index(name = "idx_user_email", columnList = "email"),
           @Index(name = "idx_user_status_created", columnList = "status, created_at")
       })
@Getter @Setter @NoArgsConstructor
@ToString(exclude = {"orders", "profile"})  // 순환 참조 방지
@EqualsAndHashCode(of = "id")               // ID만으로 동일성 판단
public class User extends BaseTimeEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true, length = 100)
    private String email;
    
    @Column(nullable = false, length = 255)
    private String password;
    
    @Column(nullable = false, length = 50)
    private String name;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private UserStatus status = UserStatus.ACTIVE;
    
    // 연관관계 매핑
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "profile_id")
    private UserProfile profile;
    
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();
    
    // 생성자, 편의 메서드
    public User(String email, String password, String name) {
        this.email = email;
        this.password = password;
        this.name = name;
    }
    
    public void addOrder(Order order) {
        orders.add(order);
        order.setUser(this);
    }
    
    public void activate() {
        this.status = UserStatus.ACTIVE;
    }
    
    public void deactivate() {
        this.status = UserStatus.INACTIVE;
    }
}

공통 속성 분리 (@MappedSuperclass)

@MappedSuperclass
@Getter @Setter
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    @CreatedBy
    @Column(updatable = false, length = 50)
    private String createdBy;
    
    @LastModifiedBy
    @Column(length = 50)
    private String lastModifiedBy;
}

// Auditing 설정
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
    
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> {
            // Spring Security에서 현재 사용자 정보 가져오기
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication == null || !authentication.isAuthenticated() 
                || authentication instanceof AnonymousAuthenticationToken) {
                return Optional.of("system");
            }
            return Optional.of(authentication.getName());
        };
    }
}

Repository 패턴 활용

기본 Repository 인터페이스

public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
    
    // 쿼리 메서드 (Spring Data JPA가 자동으로 구현)
    Optional<User> findByEmail(String email);
    
    List<User> findByStatus(UserStatus status);
    
    @Query("SELECT u FROM User u WHERE u.name LIKE %:name% AND u.status = :status")
    Page<User> findByNameContainingAndStatus(@Param("name") String name, 
                                           @Param("status") UserStatus status, 
                                           Pageable pageable);
    
    // 네이티브 쿼리 (성능 최적화가 필요한 경우)
    @Query(value = "SELECT * FROM users u WHERE u.created_at >= :fromDate AND u.status = :status", 
           nativeQuery = true)
    List<User> findActiveUsersFromDate(@Param("fromDate") LocalDateTime fromDate,
                                     @Param("status") String status);
    
    // 수정 쿼리
    @Modifying
    @Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
    int updateUserStatus(@Param("ids") List<Long> ids, @Param("status") UserStatus status);
    
    // DTO 프로젝션 (성능 최적화)
    @Query("SELECT new com.example.dto.UserSummaryDto(u.id, u.name, u.email, u.createdAt) " +
           "FROM User u WHERE u.status = :status")
    List<UserSummaryDto> findUserSummariesByStatus(@Param("status") UserStatus status);
}

커스텀 Repository 구현

// 인터페이스
public interface UserRepositoryCustom {
    Page<User> findUsersWithComplexConditions(UserSearchCriteria criteria, Pageable pageable);
    List<UserStatDto> getUserStatistics();
}

// 구현 클래스 (반드시 'Impl' 접미사 사용)
@Repository
@RequiredArgsConstructor
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
    
    private final JPAQueryFactory queryFactory;
    
    @Override
    public Page<User> findUsersWithComplexConditions(UserSearchCriteria criteria, Pageable pageable) {
        QUser user = QUser.user;
        QUserProfile profile = QUserProfile.userProfile;
        
        BooleanBuilder builder = new BooleanBuilder();
        
        // 동적 쿼리 조건 추가
        if (StringUtils.hasText(criteria.getName())) {
            builder.and(user.name.containsIgnoreCase(criteria.getName()));
        }
        
        if (criteria.getStatus() != null) {
            builder.and(user.status.eq(criteria.getStatus()));
        }
        
        if (criteria.getCreatedFrom() != null) {
            builder.and(user.createdAt.goe(criteria.getCreatedFrom()));
        }
        
        if (criteria.getCreatedTo() != null) {
            builder.and(user.createdAt.loe(criteria.getCreatedTo()));
        }
        
        // 쿼리 실행
        QueryResults<User> results = queryFactory
                .selectFrom(user)
                .leftJoin(user.profile, profile).fetchJoin()  // N+1 문제 해결
                .where(builder)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(getOrderSpecifier(pageable.getSort(), user))
                .fetchResults();
        
        return new PageImpl<>(results.getResults(), pageable, results.getTotal());
    }
    
    @Override
    public List<UserStatDto> getUserStatistics() {
        QUser user = QUser.user;
        
        return queryFactory
                .select(Projections.constructor(UserStatDto.class,
                        user.status,
                        user.count(),
                        user.createdAt.max(),
                        user.createdAt.min()))
                .from(user)
                .groupBy(user.status)
                .fetch();
    }
    
    private OrderSpecifier<?> getOrderSpecifier(Sort sort, QUser user) {
        if (sort.isEmpty()) {
            return user.createdAt.desc();
        }
        
        for (Sort.Order order : sort) {
            switch (order.getProperty()) {
                case "name":
                    return order.isAscending() ? user.name.asc() : user.name.desc();
                case "email":
                    return order.isAscending() ? user.email.asc() : user.email.desc();
                case "createdAt":
                    return order.isAscending() ? user.createdAt.asc() : user.createdAt.desc();
            }
        }
        
        return user.createdAt.desc();
    }
}

N+1 문제 해결과 성능 최적화

Fetch Join 사용

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // ❌ N+1 문제 발생
    @Query("SELECT o FROM Order o WHERE o.user.status = :status")
    List<Order> findOrdersByUserStatus(@Param("status") UserStatus status);
    
    // ✅ Fetch Join으로 해결
    @Query("SELECT o FROM Order o JOIN FETCH o.user u WHERE u.status = :status")
    List<Order> findOrdersByUserStatusWithUser(@Param("status") UserStatus status);
    
    // ✅ 여러 연관관계도 한 번에 가져오기
    @Query("SELECT DISTINCT o FROM Order o " +
           "JOIN FETCH o.user u " +
           "JOIN FETCH o.orderItems oi " +
           "JOIN FETCH oi.product p " +
           "WHERE u.status = :status")
    List<Order> findOrdersWithAllAssociations(@Param("status") UserStatus status);
}

@EntityGraph 활용

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 특정 연관관계만 즉시 로딩
    @EntityGraph(attributePaths = {"profile"})
    Optional<User> findWithProfileById(Long id);
    
    // 여러 연관관계 즉시 로딩
    @EntityGraph(attributePaths = {"profile", "orders"})
    @Query("SELECT u FROM User u WHERE u.status = :status")
    List<User> findUsersWithProfileAndOrders(@Param("status") UserStatus status);
    
    // 동적 EntityGraph
    @EntityGraph(attributePaths = {"profile", "orders", "orders.orderItems"})
    Optional<User> findWithAllAssociationsById(Long id);
}

DTO 프로젝션으로 성능 최적화

// DTO 클래스
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserOrderSummaryDto {
    private Long userId;
    private String userName;
    private String userEmail;
    private Long totalOrders;
    private BigDecimal totalAmount;
    private LocalDateTime lastOrderDate;
}

// Repository에서 직접 DTO 조회
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    @Query("SELECT new com.example.dto.UserOrderSummaryDto(" +
           "u.id, u.name, u.email, " +
           "COUNT(o.id), COALESCE(SUM(o.totalAmount), 0), MAX(o.createdAt)) " +
           "FROM User u LEFT JOIN u.orders o " +
           "WHERE u.status = :status " +
           "GROUP BY u.id, u.name, u.email")
    List<UserOrderSummaryDto> findUserOrderSummaries(@Param("status") UserStatus status);
}

🏊‍♂️ Connection Pool 최적화 (실무 경험)

HikariCP 설정 (Spring Boot 기본)

# application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/myapp?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
    username: ${DB_USERNAME:app_user}
    password: ${DB_PASSWORD:password}
    driver-class-name: com.mysql.cj.jdbc.Driver
    
    # HikariCP 설정
    hikari:
      # 풀 크기 설정
      maximum-pool-size: 20        # 최대 커넥션 수
      minimum-idle: 5              # 최소 유휴 커넥션 수
      
      # 타임아웃 설정
      connection-timeout: 30000    # 커넥션 획득 대기 시간 (30초)
      idle-timeout: 600000         # 유휴 커넥션 유지 시간 (10분)
      max-lifetime: 1800000        # 커넥션 최대 생존 시간 (30분)
      
      # 검증 설정
      validation-timeout: 5000     # 커넥션 유효성 검사 대기 시간
      connection-test-query: SELECT 1  # MySQL용 검증 쿼리
      
      # 성능 설정
      leak-detection-threshold: 60000  # 커넥션 누수 감지 (60초)
      pool-name: MyAppHikariPool
      
      # 추가 설정
      auto-commit: true
      read-only: false
      
  jpa:
    # 배치 작업 최적화
    properties:
      hibernate:
        jdbc:
          batch_size: 50           # 배치 INSERT 크기
          batch_versioned_data: true
        order_inserts: true        # INSERT 순서 최적화
        order_updates: true        # UPDATE 순서 최적화
        connection:
          provider_disables_autocommit: true  # 성능 향상

환경별 Connection Pool 설정

Local 개발 환경

# application-local.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    hikari:
      maximum-pool-size: 5         # 개발환경은 적은 커넥션
      minimum-idle: 2
      connection-timeout: 10000
      idle-timeout: 300000         # 5분
      max-lifetime: 900000         # 15분

운영 환경 (고가용성)

# application-prod.yml
spring:
  datasource:
    url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=true&requireSSL=true&serverTimezone=UTC
    hikari:
      # vCPU 4개 기준 최적화 설정
      maximum-pool-size: 25        # vCPU × 6 (DB 집약적 작업이 많은 경우)
      minimum-idle: 10             # maximum-pool-size의 40%
      
      connection-timeout: 20000    # 20초 (부하 상황 고려)
      idle-timeout: 600000         # 10분
      max-lifetime: 1800000        # 30분 (MySQL wait_timeout보다 짧게)
      
      validation-timeout: 3000
      connection-test-query: SELECT 1
      leak-detection-threshold: 30000  # 30초 (더 엄격하게)
      
      # 데이터 소스 properties (MySQL 최적화)
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        prepStmtCacheSqlLimit: 2048
        useServerPrepStmts: true
        useLocalSessionState: true
        rewriteBatchedStatements: true
        cacheResultSetMetadata: true
        cacheServerConfiguration: true
        elideSetAutoCommits: true
        maintainTimeStats: false

Connection Pool 모니터링

@Component
@RequiredArgsConstructor
@Slf4j
public class ConnectionPoolMonitor {
    
    private final DataSource dataSource;
    private final MeterRegistry meterRegistry;
    
    @PostConstruct
    public void registerMetrics() {
        if (dataSource instanceof HikariDataSource) {
            HikariDataSource hikariDS = (HikariDataSource) dataSource;
            
            // Micrometer 메트릭 등록
            Gauge.builder("hikari.connections.active")
                    .description("Active connections")
                    .register(meterRegistry, hikariDS, HikariDataSource::getHikariPoolMXBean)
                    .map(bean -> bean.getActiveConnections());
            
            Gauge.builder("hikari.connections.idle")
                    .description("Idle connections")
                    .register(meterRegistry, hikariDS, ds -> ds.getHikariPoolMXBean().getIdleConnections());
            
            Gauge.builder("hikari.connections.total")
                    .description("Total connections")
                    .register(meterRegistry, hikariDS, ds -> ds.getHikariPoolMXBean().getTotalConnections());
        }
    }
    
    @Scheduled(fixedRate = 60000) // 1분마다
    public void logConnectionPoolStatus() {
        if (dataSource instanceof HikariDataSource) {
            HikariDataSource hikariDS = (HikariDataSource) dataSource;
            HikariPoolMXBean poolBean = hikariDS.getHikariPoolMXBean();
            
            log.info("Connection Pool Status - Active: {}, Idle: {}, Total: {}, Waiting: {}",
                    poolBean.getActiveConnections(),
                    poolBean.getIdleConnections(), 
                    poolBean.getTotalConnections(),
                    poolBean.getThreadsAwaitingConnection());
            
            // 경고 임계치 체크
            if (poolBean.getActiveConnections() > hikariDS.getMaximumPoolSize() * 0.8) {
                log.warn("Connection pool usage is high: {}%", 
                        (poolBean.getActiveConnections() * 100.0) / hikariDS.getMaximumPoolSize());
            }
        }
    }
}

Connection Pool 크기 계산 공식

@Component
public class ConnectionPoolSizeCalculator {
    
    /**
     * 최적 Connection Pool 크기 계산
     * 
     * 공식: connections = ((core_count * 2) + effective_spindle_count)
     * 
     * 하지만 실무에서는 다음 요소들을 고려:
     * 1. vCPU 수
     * 2. 애플리케이션 특성 (I/O 집약적 vs CPU 집약적)
     * 3. 평균 쿼리 실행 시간
     * 4. 동시 사용자 수
     */
    public int calculateOptimalPoolSize(
            int vCpuCount, 
            ApplicationType appType, 
            int avgQueryTimeMs, 
            int expectedConcurrentUsers) {
        
        int baseSize;
        
        switch (appType) {
            case IO_INTENSIVE:
                // I/O 집약적: 대부분 시간을 DB 대기에 소모
                baseSize = vCpuCount * 4;  // 예: 4 vCPU → 16 connections
                break;
            case CPU_INTENSIVE:
                // CPU 집약적: 대부분 시간을 계산에 소모  
                baseSize = vCpuCount + 1;  // 예: 4 vCPU → 5 connections
                break;
            case BALANCED:
            default:
                // 균형적: 일반적인 웹 애플리케이션
                baseSize = vCpuCount * 2;  // 예: 4 vCPU → 8 connections
                break;
        }
        
        // 쿼리 실행 시간이 길면 더 많은 커넥션 필요
        if (avgQueryTimeMs > 100) {
            baseSize = (int) (baseSize * 1.5);
        }
        
        // 동시 사용자 수 고려 (최소 보장)
        int minConnections = Math.min(expectedConcurrentUsers / 10, baseSize);
        
        // 최종 크기 계산 (최소 5개, 최대 50개)
        return Math.max(5, Math.min(50, Math.max(baseSize, minConnections)));
    }
    
    enum ApplicationType {
        IO_INTENSIVE,      // 배치 작업, 대용량 데이터 처리
        CPU_INTENSIVE,     // 복잡한 계산 로직
        BALANCED          // 일반적인 웹 애플리케이션
    }
}

⚡ Spring Batch 완전 정복

Spring Batch 핵심 개념

Job 구조 설계

@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class UserDataMigrationJobConfig {
    
    private final JobBuilderFactory jobBuilderFactory;
    private final StepBuilderFactory stepBuilderFactory;
    private final DataSource dataSource;
    private final EntityManagerFactory entityManagerFactory;
    
    @Bean
    public Job userDataMigrationJob() {
        return jobBuilderFactory.get("userDataMigrationJob")
                .start(extractUsersStep())
                .next(transformDataStep()) 
                .next(loadUsersStep())
                .next(cleanupStep())
                .build();
    }
    
    @Bean
    public Step extractUsersStep() {
        return stepBuilderFactory.get("extractUsersStep")
                .<LegacyUser, User>chunk(1000)  // 1000개씩 처리
                .reader(legacyUserReader())
                .processor(userDataTransformer())
                .writer(userDataWriter())
                .faultTolerant()                // 내결함성 설정
                .skipLimit(100)                 // 최대 100개 오류 허용
                .skip(DataIntegrityViolationException.class)
                .retryLimit(3)                  // 최대 3회 재시도
                .retry(DeadlockLoserDataAccessException.class)
                .build();
    }
}

Reader 구현 (데이터 읽기)

JPA Reader

@Configuration
public class BatchReaderConfig {
    
    @Bean
    @StepScope
    public JpaPagingItemReader<User> userJpaReader() {
        return new JpaPagingItemReaderBuilder<User>()
                .name("userJpaReader")
                .entityManagerFactory(entityManagerFactory)
                .queryString("SELECT u FROM User u WHERE u.status = 'ACTIVE' ORDER BY u.id")
                .pageSize(1000)
                .saveState(false)  // 재시작 불가능하지만 성능 향상
                .build();
    }
    
    @Bean
    @StepScope
    public JpaPagingItemReader<User> parameterizedUserReader(
            @Value("#{jobParameters[status]}") String status,
            @Value("#{jobParameters[fromDate]}") String fromDateStr) {
        
        LocalDateTime fromDate = LocalDateTime.parse(fromDateStr);
        
        return new JpaPagingItemReaderBuilder<User>()
                .name("parameterizedUserReader")
                .entityManagerFactory(entityManagerFactory)
                .queryString("SELECT u FROM User u WHERE u.status = :status AND u.createdAt >= :fromDate ORDER BY u.id")
                .parameterValues(Map.of("status", UserStatus.valueOf(status), "fromDate", fromDate))
                .pageSize(1000)
                .build();
    }
}

Custom Reader (복잡한 로직)

@Component
@StepScope
@Slf4j
public class ComplexDataReader implements ItemReader<ProcessingData> {
    
    private final ExternalApiService externalApiService;
    private final UserRepository userRepository;
    private Iterator<User> userIterator;
    private boolean initialized = false;
    
    public ComplexDataReader(ExternalApiService externalApiService, 
                           UserRepository userRepository) {
        this.externalApiService = externalApiService;
        this.userRepository = userRepository;
    }
    
    @Override
    public ProcessingData read() throws Exception {
        if (!initialized) {
            initialize();
        }
        
        if (!userIterator.hasNext()) {
            return null;  // 더 이상 읽을 데이터 없음
        }
        
        User user = userIterator.next();
        
        try {
            // 외부 API 호출하여 추가 데이터 수집
            ExternalUserData externalData = externalApiService.getUserData(user.getId());
            
            return ProcessingData.builder()
                    .user(user)
                    .externalData(externalData)
                    .processedAt(LocalDateTime.now())
                    .build();
                    
        } catch (Exception e) {
            log.error("Error reading data for user: {}", user.getId(), e);
            throw e;  // Skip/Retry 정책에 따라 처리됨
        }
    }
    
    private void initialize() {
        List<User> users = userRepository.findActiveUsers();
        this.userIterator = users.iterator();
        this.initialized = true;
        log.info("Initialized reader with {} users", users.size());
    }
}

Processor 구현 (데이터 변환)

@Component
@StepScope
@Slf4j
public class UserDataProcessor implements ItemProcessor<User, EnrichedUser> {
    
    private final AddressService addressService;
    private final ValidationService validationService;
    
    @Value("#{jobParameters[enrichmentLevel]}")
    private String enrichmentLevel;
    
    public UserDataProcessor(AddressService addressService, 
                           ValidationService validationService) {
        this.addressService = addressService;
        this.validationService = validationService;
    }
    
    @Override
    public EnrichedUser process(User user) throws Exception {
        // 1. 데이터 검증
        if (!validationService.isValid(user)) {
            log.warn("Invalid user data: {}", user.getId());
            return null;  // null 반환시 해당 아이템은 Writer로 전달되지 않음
        }
        
        // 2. 데이터 변환
        EnrichedUser enrichedUser = EnrichedUser.builder()
                .originalUser(user)
                .processedAt(LocalDateTime.now())
                .enrichmentLevel(enrichmentLevel)
                .build();
        
        // 3. 외부 데이터 추가 (선택적)
        if ("FULL".equals(enrichmentLevel)) {
            try {
                AddressInfo addressInfo = addressService.getAddressInfo(user.getAddress());
                enrichedUser.setAddressInfo(addressInfo);
            } catch (Exception e) {
                log.warn("Failed to enrich address for user: {}", user.getId(), e);
                // 계속 처리 (부분적 실패 허용)
            }
        }
        
        return enrichedUser;
    }
}

Writer 구현 (데이터 저장)

JPA Writer

@Configuration
public class BatchWriterConfig {
    
    @Bean
    public JpaItemWriter<EnrichedUser> enrichedUserWriter(EntityManagerFactory entityManagerFactory) {
        return new JpaItemWriterBuilder<EnrichedUser>()
                .entityManagerFactory(entityManagerFactory)
                .usePersist(true)  // merge 대신 persist 사용 (성능 향상)
                .build();
    }
}

Custom Writer (복합 작업)

@Component
@StepScope
@Slf4j
@Transactional
public class ComplexDataWriter implements ItemWriter<ProcessedData> {
    
    private final UserRepository userRepository;
    private final AuditLogRepository auditLogRepository;
    private final NotificationService notificationService;
    
    public ComplexDataWriter(UserRepository userRepository,
                           AuditLogRepository auditLogRepository, 
                           NotificationService notificationService) {
        this.userRepository = userRepository;
        this.auditLogRepository = auditLogRepository;
        this.notificationService = notificationService;
    }
    
    @Override
    public void write(List<? extends ProcessedData> items) throws Exception {
        log.info("Writing {} items", items.size());
        
        List<User> usersToSave = new ArrayList<>();
        List<AuditLog> auditLogs = new ArrayList<>();
        List<NotificationRequest> notifications = new ArrayList<>();
        
        // 1. 데이터 분류 및 변환
        for (ProcessedData item : items) {
            if (item.shouldUpdateUser()) {
                User user = item.getUser();
                user.updateFromProcessedData(item);
                usersToSave.add(user);
                
                // 감사 로그 생성
                auditLogs.add(AuditLog.builder()
                        .entityType("User")
                        .entityId(user.getId())
                        .action("BATCH_UPDATE")
                        .details(item.getChangeDetails())
                        .timestamp(LocalDateTime.now())
                        .build());
            }
            
            if (item.shouldSendNotification()) {
                notifications.add(NotificationRequest.builder()
                        .userId(item.getUser().getId())
                        .type(item.getNotificationType())
                        .message(item.getNotificationMessage())
                        .build());
            }
        }
        
        // 2. 배치 저장 (성능 최적화)
        if (!usersToSave.isEmpty()) {
            userRepository.saveAll(usersToSave);
            log.info("Saved {} users", usersToSave.size());
        }
        
        if (!auditLogs.isEmpty()) {
            auditLogRepository.saveAll(auditLogs);
            log.info("Saved {} audit logs", auditLogs.size());
        }
        
        // 3. 비동기 알림 전송
        if (!notifications.isEmpty()) {
            notificationService.sendBatchNotifications(notifications);
            log.info("Queued {} notifications", notifications.size());
        }
    }
}

배치 실행과 모니터링

Job 실행 구성

@RestController
@RequestMapping("/api/batch")
@RequiredArgsConstructor
public class BatchController {
    
    private final JobLauncher jobLauncher;
    private final Job userDataMigrationJob;
    
    @PostMapping("/users/migration")
    public ResponseEntity<BatchExecutionResponse> startUserMigration(
            @RequestBody BatchExecutionRequest request) {
        
        try {
            JobParameters jobParameters = new JobParametersBuilder()
                    .addString("executionId", UUID.randomUUID().toString())
                    .addDate("executionDate", new Date())
                    .addString("status", request.getStatus())
                    .addString("fromDate", request.getFromDate())
                    .addLong("batchSize", request.getBatchSize())
                    .toJobParameters();
            
            JobExecution jobExecution = jobLauncher.run(userDataMigrationJob, jobParameters);
            
            return ResponseEntity.ok(BatchExecutionResponse.builder()
                    .jobExecutionId(jobExecution.getId())
                    .status(jobExecution.getStatus().name())
                    .startTime(jobExecution.getStartTime())
                    .build());
                    
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(BatchExecutionResponse.builder()
                            .error("Failed to start batch job: " + e.getMessage())
                            .build());
        }
    }
    
    @GetMapping("/jobs/{jobExecutionId}/status")
    public ResponseEntity<JobExecutionStatus> getJobStatus(@PathVariable Long jobExecutionId) {
        // JobExplorer를 사용한 상태 조회 구현
        return ResponseEntity.ok(jobExecutionStatusService.getStatus(jobExecutionId));
    }
}

배치 성능 최적화

@Configuration
public class BatchPerformanceConfig {
    
    @Bean
    public TaskExecutor batchTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("Batch-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
    
    // 파티션 처리를 위한 Step 구성
    @Bean
    public Step partitionedStep() {
        return stepBuilderFactory.get("partitionedStep")
                .partitioner("slaveStep", partitioner())
                .step(slaveStep())
                .gridSize(4)  // 파티션 수
                .taskExecutor(batchTaskExecutor())
                .build();
    }
    
    @Bean
    public Partitioner partitioner() {
        return new ColumnRangePartitioner();  // ID 범위별 파티셔닝
    }
    
    @Bean 
    public Step slaveStep() {
        return stepBuilderFactory.get("slaveStep")
                .<User, ProcessedUser>chunk(5000)  // 큰 청크 사이즈
                .reader(partitionedReader())
                .processor(batchProcessor())
                .writer(batchWriter())
                .build();
    }
}

🔄 트랜잭션 관리 전략

트랜잭션 전파 설정

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // 기본값: 읽기 전용
public class UserService {
    
    private final UserRepository userRepository;
    private final NotificationService notificationService;
    private final AuditService auditService;
    
    // 새 트랜잭션에서 실행 (독립적)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public User createUserWithNewTransaction(CreateUserRequest request) {
        User user = User.builder()
                .email(request.getEmail())
                .name(request.getName())
                .password(passwordEncoder.encode(request.getPassword()))
                .status(UserStatus.ACTIVE)
                .build();
                
        User savedUser = userRepository.save(user);
        
        // 감사 로그는 별도 트랜잭션에서 (실패해도 사용자 생성은 성공)
        auditService.logUserCreation(savedUser.getId());
        
        return savedUser;
    }
    
    // 기존 트랜잭션에 참여, 없으면 새로 생성
    @Transactional
    public User updateUser(Long userId, UpdateUserRequest request) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new UserNotFoundException(userId));
        
        user.updateName(request.getName());
        user.updateStatus(request.getStatus());
        
        User updatedUser = userRepository.save(user);
        
        // 같은 트랜잭션에서 실행 (하나라도 실패하면 모두 롤백)
        notificationService.sendUpdateNotification(updatedUser);
        
        return updatedUser;
    }
    
    // 트랜잭션 없이 실행 (성능 최적화)
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public List<UserSummaryDto> getUserSummaries() {
        // 읽기 전용 작업은 트랜잭션 오버헤드 제거
        return userRepository.findUserSummaries();
    }
    
    // 중첩 트랜잭션 (세이브포인트 활용)
    @Transactional
    public BatchProcessResult processBatchUsers(List<Long> userIds) {
        BatchProcessResult result = new BatchProcessResult();
        
        for (Long userId : userIds) {
            try {
                // 각 사용자 처리를 별도 세이브포인트에서
                processUserInNestedTransaction(userId);
                result.addSuccess(userId);
            } catch (Exception e) {
                log.error("Failed to process user: {}", userId, e);
                result.addFailure(userId, e.getMessage());
                // 전체 트랜잭션은 계속 진행
            }
        }
        
        return result;
    }
    
    @Transactional(propagation = Propagation.NESTED)
    protected void processUserInNestedTransaction(Long userId) {
        // 개별 사용자 처리 로직
        // 실패 시 이 부분만 롤백, 전체 트랜잭션은 유지
    }
}

트랜잭션 격리 수준 설정

@Service
@Transactional
public class OrderService {
    
    // 기본 격리 수준 (DB 기본값 사용)
    public Order createOrder(CreateOrderRequest request) {
        // 일반적인 주문 생성
    }
    
    // READ_COMMITTED: 커밋된 데이터만 읽기 (일반적)
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public List<Order> getOrderHistory(Long userId) {
        return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
    }
    
    // SERIALIZABLE: 최고 수준 격리 (성능 저하, 신중히 사용)
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void processPayment(Long orderId, PaymentRequest request) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        // 중요한 결제 처리 - 다른 트랜잭션 간섭 완전 차단
        paymentProcessor.processPayment(order, request);
    }
    
    // REPEATABLE_READ: 같은 데이터를 여러 번 읽어도 일관성 보장
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public OrderStatistics calculateOrderStatistics(Long userId) {
        List<Order> orders = orderRepository.findByUserId(userId);
        
        // 통계 계산 중에 주문 데이터가 변경되지 않음을 보장
        return OrderStatistics.builder()
                .totalOrders(orders.size())
                .totalAmount(orders.stream()
                        .map(Order::getTotalAmount)
                        .reduce(BigDecimal.ZERO, BigDecimal::add))
                .build();
    }
}

🚀 Redis 캐싱 활용법

Redis 설정

# application.yml
spring:
  redis:
    host: ${REDIS_HOST:localhost}
    port: ${REDIS_PORT:6379}
    password: ${REDIS_PASSWORD:}
    timeout: 2000ms
    
    # Connection Pool (Lettuce)
    lettuce:
      pool:
        max-active: 20
        max-idle: 8
        min-idle: 2
        max-wait: 2000ms
        
  cache:
    type: redis
    redis:
      time-to-live: 600000    # 10분
      cache-null-values: false
      key-prefix: "myapp:"
      use-key-prefix: true

캐시 설정 클래스

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))  // 기본 TTL 10분
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        
        // 캐시별 개별 설정
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        
        // 사용자 정보: 1시간 캐시
        cacheConfigurations.put("users", config.entryTtl(Duration.ofHours(1)));
        
        // 주문 정보: 30분 캐시  
        cacheConfigurations.put("orders", config.entryTtl(Duration.ofMinutes(30)));
        
        // 시스템 설정: 1일 캐시
        cacheConfigurations.put("settings", config.entryTtl(Duration.ofDays(1)));
        
        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .withInitialCacheConfigurations(cacheConfigurations)
                .build();
    }
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        
        // Key Serializer
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        
        // Value Serializer  
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        
        template.afterPropertiesSet();
        return template;
    }
}

캐시 어노테이션 활용

@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
    
    private final UserRepository userRepository;
    
    // 결과 캐싱
    @Cacheable(value = "users", key = "#id", condition = "#id != null")
    public UserDto findById(Long id) {
        log.info("Finding user by id: {} from database", id);
        
        return userRepository.findById(id)
                .map(UserDto::from)
                .orElseThrow(() -> new UserNotFoundException(id));
    }
    
    // 복잡한 키 생성
    @Cacheable(value = "users", 
               key = "#status + ':' + #pageable.pageNumber + ':' + #pageable.pageSize")
    public Page<UserDto> findByStatus(UserStatus status, Pageable pageable) {
        log.info("Finding users by status: {} from database", status);
        
        return userRepository.findByStatus(status, pageable)
                .map(UserDto::from);
    }
    
    // 조건부 캐싱
    @Cacheable(value = "expensive-calculations", 
               key = "#userId", 
               condition = "#useCache == true",
               unless = "#result.isEmpty()")
    public List<UserStatistics> calculateUserStatistics(Long userId, boolean useCache) {
        log.info("Calculating statistics for user: {}", userId);
        
        // 복잡한 통계 계산 로직
        return performExpensiveCalculation(userId);
    }
    
    // 캐시 업데이트
    @CachePut(value = "users", key = "#result.id")
    public UserDto update(Long id, UpdateUserRequest request) {
        log.info("Updating user: {}", id);
        
        User user = userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
        
        user.update(request);
        User updatedUser = userRepository.save(user);
        
        return UserDto.from(updatedUser);
    }
    
    // 캐시 삭제
    @CacheEvict(value = "users", key = "#id")
    public void delete(Long id) {
        log.info("Deleting user: {}", id);
        userRepository.deleteById(id);
    }
    
    // 여러 캐시 삭제
    @CacheEvict(value = {"users", "user-statistics"}, key = "#id")
    public void deleteUserCompletely(Long id) {
        userRepository.deleteById(id);
        // 관련된 모든 캐시 삭제
    }
    
    // 전체 캐시 삭제
    @CacheEvict(value = "users", allEntries = true)
    public void clearUserCache() {
        log.info("Clearing all user cache");
    }
}

직접 Redis 조작

@Service
@RequiredArgsConstructor
public class SessionService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;
    
    // 세션 저장
    public void saveSession(String sessionId, UserSession session) {
        String key = "session:" + sessionId;
        redisTemplate.opsForValue().set(key, session, Duration.ofHours(2));
    }
    
    // 세션 조회
    public Optional<UserSession> getSession(String sessionId) {
        String key = "session:" + sessionId;
        UserSession session = (UserSession) redisTemplate.opsForValue().get(key);
        return Optional.ofNullable(session);
    }
    
    // 카운터 증가 (원자적 연산)
    public Long incrementCounter(String key) {
        return stringRedisTemplate.opsForValue().increment(key);
    }
    
    // 순위 정보 (Sorted Set 활용)
    public void updateUserScore(Long userId, double score) {
        String key = "leaderboard";
        redisTemplate.opsForZSet().add(key, userId.toString(), score);
    }
    
    public List<String> getTopUsers(int count) {
        String key = "leaderboard";
        Set<String> topUsers = redisTemplate.opsForZSet()
                .reverseRange(key, 0, count - 1);
        return new ArrayList<>(topUsers);
    }
    
    // 분산 락 구현
    public boolean acquireLock(String lockKey, String identifier, Duration expiration) {
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent("lock:" + lockKey, identifier, expiration);
        return Boolean.TRUE.equals(success);
    }
    
    public void releaseLock(String lockKey, String identifier) {
        // Lua 스크립트로 원자적 삭제 (자신이 생성한 락만 삭제)
        String luaScript = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "    return redis.call('del', KEYS[1]) " +
            "else " +
            "    return 0 " +
            "end";
        
        redisTemplate.execute(
                new DefaultRedisScript<>(luaScript, Long.class),
                Collections.singletonList("lock:" + lockKey),
                identifier
        );
    }
}

🎯 Part 4 정리

핵심 포인트 요약

  1. Spring Data JPA:
    • Entity 설계와 연관관계 매핑
    • N+1 문제 해결 (Fetch Join, @EntityGraph)
    • QueryDSL을 활용한 동적 쿼리
    • DTO 프로젝션으로 성능 최적화
  2. Connection Pool 최적화:
    • HikariCP 설정과 튜닝
    • 환경별 최적화 전략
    • 모니터링과 알람 설정
  3. Spring Batch:
    • Job, Step, Reader, Processor, Writer 구조
    • 파티셔닝을 활용한 병렬 처리
    • 내결함성과 재시작 전략
  4. 트랜잭션 관리:
    • 전파 속성과 격리 수준
    • 중첩 트랜잭션과 세이브포인트
    • 성능을 고려한 트랜잭션 전략
  5. Redis 캐싱:
    • 캐시 어노테이션 활용
    • TTL과 캐시 무효화 전략
    • 분산 락과 카운터 구현

실습 체크리스트

  •  JPA Entity와 Repository 구현
  •  N+1 문제 해결 실습
  •  HikariCP Connection Pool 최적화
  •  Spring Batch Job 구현
  •  트랜잭션 전파 속성 테스트
  •  Redis 캐시 적용
  •  배치 성능 모니터링 구현

다음 편 예고

Part 5: 운영과 모니터링에서는:

  • Spring Boot Actuator 완전 활용
  • 로깅 전략과 구조화된 로깅
  • 애플리케이션 성능 모니터링 (APM)
  • Docker 컨테이너화와 배포 자동화
  • 무중단 배포와 블루-그린 전략

운영 환경에서 안정적으로 서비스를 운영하기 위한 모든 노하우를 공유합니다! 📊


📚 참고 자료:

🏷️ 태그: #SpringBoot #SpringDataJPA #SpringBatch #ConnectionPool #Redis #실무가이드