Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
Tags
- 프로덕션 운영
- 서비스 설계
- 인메모리데이터베이스
- 이벤트 스트리밍
- 클라우드
- 마이크로서비스
- 세션저장소
- infrastructureascode
- 클러스터
- 메시징 패턴
- rabbitmq
- 분산 시스템
- 모니터링
- 고가용성
- 마이크로서비스 운영
- 컨테이너오케스트레이션
- 마이크로서비스 통신
- RabbitMQ Exchange
- 서비스 메시
- 보안
- Python
- kubernetes
- Kafka 클러스터
- 메시지 브로커
- 모노리스 분해
- devops
- ApacheBench
- CI/CD
- 분산 모니터링
- docker
Archives
- Today
- Total
hobokai 님의 블로그
Spring Boot 완전 가이드 Part 4: 데이터 액세스와 배치 본문
시리즈 소개: 실무에서 바로 써먹는 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 정리
핵심 포인트 요약
- Spring Data JPA:
- Entity 설계와 연관관계 매핑
- N+1 문제 해결 (Fetch Join, @EntityGraph)
- QueryDSL을 활용한 동적 쿼리
- DTO 프로젝션으로 성능 최적화
- Connection Pool 최적화:
- HikariCP 설정과 튜닝
- 환경별 최적화 전략
- 모니터링과 알람 설정
- Spring Batch:
- Job, Step, Reader, Processor, Writer 구조
- 파티셔닝을 활용한 병렬 처리
- 내결함성과 재시작 전략
- 트랜잭션 관리:
- 전파 속성과 격리 수준
- 중첩 트랜잭션과 세이브포인트
- 성능을 고려한 트랜잭션 전략
- 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 #실무가이드
'Backend' 카테고리의 다른 글
| OAuth2 & JWT 고급 활용 가이드 (0) | 2025.08.07 |
|---|---|
| Spring Boot 완전 가이드 Part 5: 운영과 모니터링 (1) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 3: 웹 개발과 REST API (3) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 2: 설정과 자동구성 (2) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 1: 기초편 - 시작하기 (2) | 2025.08.06 |