들어가며
데이터베이스 커넥션은 애플리케이션에서 가장 중요하면서도 제한적인 자원 중 하나입니다. 커넥션을 효율적으로 관리하지 못하면 성능 저하, 응답 지연, 심지어 서비스 장애까지 발생할 수 있습니다. 이번 포스트에서는 커넥션 풀의 개념부터 실제 최적화 방법까지 상세히 알아보겠습니다.
커넥션 풀의 이해
커넥션 풀이란
커넥션 풀은 데이터베이스 연결을 미리 생성해서 풀에 저장해두고, 필요할 때마다 재사용하는 메커니즘입니다. 매번 새로운 커넥션을 생성하고 종료하는 비용을 절약할 수 있습니다.
커넥션 풀 없이 사용할 때의 문제점
// 문제가 되는 코드 - 매번 새로운 커넥션 생성
public List<User> getUsers() {
Connection conn = null;
try {
// 1. 커넥션 생성 (비용 높음)
conn = DriverManager.getConnection(url, username, password);
// 2. 쿼리 실행
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users");
ResultSet rs = stmt.executeQuery();
// 3. 결과 처리
List<User> users = new ArrayList<>();
while (rs.next()) {
users.add(mapToUser(rs));
}
return users;
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
// 4. 커넥션 종료 (자원 낭비)
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
문제점:
- 커넥션 생성/해제 오버헤드
- 동시 요청 처리 한계
- 자원 낭비
- 성능 저하
Spring Boot에서 커넥션 풀 설정
HikariCP 설정 (Spring Boot 기본)
application.yml 설정:
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: user
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
# HikariCP 설정
hikari:
# 풀에서 유지할 최소 커넥션 수
minimum-idle: 10
# 풀에서 유지할 최대 커넥션 수
maximum-pool-size: 20
# 커넥션을 얻기 위해 대기할 최대 시간 (밀리초)
connection-timeout: 30000
# 커넥션이 유효한지 확인하는 최대 시간 (밀리초)
validation-timeout: 5000
# 커넥션이 유휴 상태로 유지될 수 있는 최대 시간 (밀리초)
idle-timeout: 600000
# 커넥션의 최대 생명주기 (밀리초)
max-lifetime: 1800000
# 커넥션이 유효한지 확인하는 쿼리
connection-test-query: SELECT 1
# 풀 이름
pool-name: MyApp-Pool
# 자동 커밋 설정
auto-commit: true
# 누수 감지 임계값 (밀리초)
leak-detection-threshold: 60000
상세 설정 옵션 설명
1. 풀 크기 설정
# 최소 유지 커넥션 수
minimum-idle: 10
# 최대 커넥션 수
maximum-pool-size: 20
최적 값 계산:
- CPU 코어 수 × 2 + 디스크 수 (일반적인 공식)
- 실제 부하 테스트를 통한 조정 필요
2. 타임아웃 설정
# 커넥션 획득 대기 시간
connection-timeout: 30000 # 30초
# 유휴 커넥션 제거 시간
idle-timeout: 600000 # 10분
# 커넥션 최대 생존 시간
max-lifetime: 1800000 # 30분
커넥션 풀 모니터링
1. 메트릭 수집 설정
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public HikariConfig hikariConfig() {
HikariConfig config = new HikariConfig();
// JMX를 통한 모니터링 활성화
config.setRegisterMbeans(true);
// 메트릭 레지스트리 설정
config.setMetricRegistry(new MetricRegistry());
return config;
}
@Bean
public DataSource dataSource(HikariConfig hikariConfig) {
return new HikariDataSource(hikariConfig);
}
}
2. 커스텀 모니터링
@Component
public class ConnectionPoolMonitor {
@Autowired
private DataSource dataSource;
@Scheduled(fixedRate = 30000) // 30초마다 실행
public void monitorConnectionPool() {
if (dataSource instanceof HikariDataSource) {
HikariDataSource hikariDS = (HikariDataSource) dataSource;
HikariPoolMXBean poolBean = hikariDS.getHikariPoolMXBean();
int activeConnections = poolBean.getActiveConnections();
int idleConnections = poolBean.getIdleConnections();
int totalConnections = poolBean.getTotalConnections();
int threadsAwaitingConnection = poolBean.getThreadsAwaitingConnection();
log.info("Connection Pool Status - Active: {}, Idle: {}, Total: {}, Waiting: {}",
activeConnections, idleConnections, totalConnections, threadsAwaitingConnection);
// 경고 조건 체크
if (activeConnections > totalConnections * 0.8) {
log.warn("Connection pool usage is high: {}%",
(activeConnections * 100.0 / totalConnections));
}
if (threadsAwaitingConnection > 0) {
log.warn("Threads waiting for connection: {}", threadsAwaitingConnection);
}
}
}
}
3. Actuator를 통한 모니터링
management:
endpoints:
web:
exposure:
include: health,metrics,hikaricp
endpoint:
health:
show-details: always
메트릭 확인:
# 커넥션 풀 상태 확인
curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
curl http://localhost:8080/actuator/metrics/hikaricp.connections.idle
curl http://localhost:8080/actuator/metrics/hikaricp.connections.pending
트랜잭션 관리 최적화
1. 트랜잭션 범위 최소화
문제가 되는 코드:
@Service
@Transactional // 전체 메서드에 트랜잭션 적용
public class UserService {
public void processUser(Long userId) {
// 1. 사용자 조회 (DB 커넥션 필요)
User user = userRepository.findById(userId);
// 2. 외부 API 호출 (DB 커넥션 불필요, 시간 오래 걸림)
ExternalApiResponse response = externalApiService.callApi(user.getEmail());
// 3. 이메일 발송 (DB 커넥션 불필요, 시간 오래 걸림)
emailService.sendEmail(user.getEmail(), response.getMessage());
// 4. 사용자 정보 업데이트 (DB 커넥션 필요)
user.setLastProcessedAt(LocalDateTime.now());
userRepository.save(user);
}
}
개선된 코드:
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private ExternalApiService externalApiService;
@Autowired
private EmailService emailService;
public void processUser(Long userId) {
// 1. 트랜잭션 없이 조회
User user = getUserById(userId);
// 2. 외부 작업 (트랜잭션 불필요)
ExternalApiResponse response = externalApiService.callApi(user.getEmail());
emailService.sendEmail(user.getEmail(), response.getMessage());
// 3. 필요한 부분만 트랜잭션 적용
updateUserLastProcessed(userId);
}
@Transactional(readOnly = true)
public User getUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
}
@Transactional
public void updateUserLastProcessed(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
user.setLastProcessedAt(LocalDateTime.now());
userRepository.save(user);
}
}
2. 읽기 전용 트랜잭션 활용
@Service
public class UserQueryService {
@Autowired
private UserRepository userRepository;
// 읽기 전용 트랜잭션 - 성능 향상
@Transactional(readOnly = true)
public List<User> getActiveUsers() {
return userRepository.findByStatus("ACTIVE");
}
// 복잡한 조회 쿼리도 읽기 전용으로
@Transactional(readOnly = true)
public UserStatistics getUserStatistics(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
long orderCount = orderRepository.countByUserId(userId);
BigDecimal totalAmount = orderRepository.sumTotalAmountByUserId(userId);
return UserStatistics.builder()
.user(user)
.orderCount(orderCount)
.totalAmount(totalAmount)
.build();
}
}
읽기 전용 트랜잭션의 장점:
- 트랜잭션 오버헤드 감소
- 데이터베이스 최적화 활용
- 더티 체킹 비활성화로 성능 향상
커넥션 풀 크기 최적화
1. 부하 테스트를 통한 최적값 찾기
@RestController
@RequestMapping("/api/load-test")
public class LoadTestController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
long startTime = System.currentTimeMillis();
try {
User user = userService.getUserById(id);
long endTime = System.currentTimeMillis();
// 응답 시간 로깅
log.info("User retrieval took {} ms", (endTime - startTime));
return ResponseEntity.ok(user);
} catch (Exception e) {
long endTime = System.currentTimeMillis();
log.error("User retrieval failed after {} ms", (endTime - startTime), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
2. 환경별 설정
개발 환경 (application-dev.yml):
spring:
datasource:
hikari:
minimum-idle: 5
maximum-pool-size: 10
connection-timeout: 20000
idle-timeout: 300000
max-lifetime: 1200000
운영 환경 (application-prod.yml):
spring:
datasource:
hikari:
minimum-idle: 20
maximum-pool-size: 50
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
3. 동적 설정 조정
@Component
public class DynamicConnectionPoolTuner {
@Autowired
private DataSource dataSource;
private final AtomicInteger consecutiveHighUsage = new AtomicInteger(0);
private final AtomicInteger consecutiveLowUsage = new AtomicInteger(0);
@Scheduled(fixedRate = 60000) // 1분마다 실행
public void adjustPoolSize() {
if (dataSource instanceof HikariDataSource) {
HikariDataSource hikariDS = (HikariDataSource) dataSource;
HikariPoolMXBean poolBean = hikariDS.getHikariPoolMXBean();
int activeConnections = poolBean.getActiveConnections();
int totalConnections = poolBean.getTotalConnections();
double usageRatio = (double) activeConnections / totalConnections;
// 사용률이 80% 이상일 때
if (usageRatio > 0.8) {
consecutiveHighUsage.incrementAndGet();
consecutiveLowUsage.set(0);
// 3회 연속 높은 사용률이면 풀 크기 증가
if (consecutiveHighUsage.get() >= 3 && totalConnections < 100) {
int newSize = Math.min(totalConnections + 5, 100);
hikariDS.getHikariConfigMXBean().setMaximumPoolSize(newSize);
log.info("Connection pool size increased to {}", newSize);
}
}
// 사용률이 30% 이하일 때
else if (usageRatio < 0.3) {
consecutiveLowUsage.incrementAndGet();
consecutiveHighUsage.set(0);
// 5회 연속 낮은 사용률이면 풀 크기 감소
if (consecutiveLowUsage.get() >= 5 && totalConnections > 10) {
int newSize = Math.max(totalConnections - 2, 10);
hikariDS.getHikariConfigMXBean().setMaximumPoolSize(newSize);
log.info("Connection pool size decreased to {}", newSize);
}
} else {
consecutiveHighUsage.set(0);
consecutiveLowUsage.set(0);
}
}
}
}
커넥션 누수 방지
1. 커넥션 누수 감지
spring:
datasource:
hikari:
# 60초 이상 반환되지 않은 커넥션을 누수로 판단
leak-detection-threshold: 60000
2. 프로그래매틱 커넥션 관리
@Service
public class ManualConnectionService {
@Autowired
private DataSource dataSource;
public List<User> getUsersWithManualConnection() {
// try-with-resources를 사용한 안전한 커넥션 관리
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE status = ?")) {
stmt.setString(1, "ACTIVE");
try (ResultSet rs = stmt.executeQuery()) {
List<User> users = new ArrayList<>();
while (rs.next()) {
users.add(mapToUser(rs));
}
return users;
}
} catch (SQLException e) {
throw new RuntimeException("사용자 조회 실패", e);
}
// 커넥션이 자동으로 반환됨
}
private User mapToUser(ResultSet rs) throws SQLException {
return User.builder()
.id(rs.getLong("id"))
.name(rs.getString("name"))
.email(rs.getString("email"))
.status(rs.getString("status"))
.build();
}
}
3. AOP를 활용한 커넥션 모니터링
@Aspect
@Component
public class ConnectionMonitoringAspect {
private static final Logger log = LoggerFactory.getLogger(ConnectionMonitoringAspect.class);
@Around("@annotation(org.springframework.transaction.annotation.Transactional)")
public Object monitorTransactionalMethod(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
log.debug("Transactional method '{}' completed in {} ms",
methodName, (endTime - startTime));
// 긴 트랜잭션 경고
if (endTime - startTime > 5000) {
log.warn("Long running transaction detected: '{}' took {} ms",
methodName, (endTime - startTime));
}
return result;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
log.error("Transactional method '{}' failed after {} ms",
methodName, (endTime - startTime), e);
throw e;
}
}
}
멀티 데이터소스 환경에서의 최적화
1. 읽기/쓰기 분리
@Configuration
public class MultiDataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource.write")
public DataSource writeDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
@Bean
@ConfigurationProperties("spring.datasource.read")
public DataSource readDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
@Bean
public DataSource routingDataSource() {
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("write", writeDataSource());
dataSourceMap.put("read", readDataSource());
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(writeDataSource());
return routingDataSource;
}
}
2. 동적 데이터소스 라우팅
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return isReadOnly ? "read" : "write";
}
}
@Service
public class UserService {
// 쓰기 데이터소스 사용
@Transactional
public User createUser(User user) {
return userRepository.save(user);
}
// 읽기 데이터소스 사용
@Transactional(readOnly = true)
public List<User> getAllUsers() {
return userRepository.findAll();
}
}
트러블슈팅 가이드
1. 커넥션 타임아웃 발생
증상:
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available
해결 방법:
- 풀 크기 증가
- 커넥션 타임아웃 시간 조정
- 트랜잭션 범위 최소화
- 커넥션 누수 점검
2. 커넥션 누수 감지
로그 예시:
WARN HikariPool - Connection leak detection triggered for connection
해결 방법:
// 문제가 되는 코드
@Transactional
public void problematicMethod() {
// 장시간 실행되는 작업
Thread.sleep(70000); // 70초 대기
}
// 개선된 코드
public void improvedMethod() {
// 트랜잭션 분리
processQuickTransaction();
performLongRunningTask(); // 트랜잭션 외부에서 실행
processAnotherQuickTransaction();
}
3. 메모리 사용량 최적화
@Component
public class ConnectionPoolHealthIndicator implements HealthIndicator {
@Autowired
private DataSource dataSource;
@Override
public Health health() {
try {
if (dataSource instanceof HikariDataSource) {
HikariDataSource hikariDS = (HikariDataSource) dataSource;
HikariPoolMXBean poolBean = hikariDS.getHikariPoolMXBean();
int activeConnections = poolBean.getActiveConnections();
int totalConnections = poolBean.getTotalConnections();
int threadsAwaitingConnection = poolBean.getThreadsAwaitingConnection();
Health.Builder builder = Health.up();
if (threadsAwaitingConnection > 0) {
builder = Health.down()
.withDetail("reason", "Threads waiting for connection");
}
return builder
.withDetail("active", activeConnections)
.withDetail("total", totalConnections)
.withDetail("waiting", threadsAwaitingConnection)
.withDetail("usage", String.format("%.2f%%",
(activeConnections * 100.0 / totalConnections)))
.build();
}
return Health.up().build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.build();
}
}
}
마무리
데이터베이스 커넥션 풀 최적화는 백엔드 애플리케이션의 성능과 안정성을 좌우하는 핵심 요소입니다. 적절한 풀 크기 설정, 트랜잭션 범위 최소화, 지속적인 모니터링을 통해 최적의 성능을 달성할 수 있습니다. 특히 부하가 증가하는 운영 환경에서는 실시간 모니터링과 동적 조정이 필수적이며, 커넥션 누수 방지를 위한 코드 품질 관리도 매우 중요합니다. 이러한 최적화를 통해 사용자에게 빠르고 안정적인 서비스를 제공할 수 있습니다.
'Spring > 백엔드 실무 지식' 카테고리의 다른 글
| 파일 저장소 분리와 CDN 활용 - 대용량 파일 처리 최적화 (0) | 2025.07.14 |
|---|---|
| 백엔드 성능 최적화 - Redis를 활용한 리모트 캐싱 (0) | 2025.07.14 |