이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Spring › 데이터 접근과 트랜잭션
커넥션 풀과 OSIV
쿼리는 금방 끝났는데 커넥션이 모자란 이유
요청은 DB 작업을 하고 나서 외부 API를 호출한 뒤 응답합니다. DB 커넥션은 풀에서 빌려 쓰고 돌려줍니다. 커넥션을 언제 돌려주느냐(OSIV 설정)에 따라, 같은 풀 크기로 버틸 수 있는 요청 수가 크게 달라집니다.
문서·표준 대조 · HikariCP·Hibernate의 기본 동작(수치는 모형)
이 실습에 나오는 개념
- 커넥션 풀
- 데이터베이스 연결을 미리 여러 개 만들어 두고 재사용하는 방식이다. 연결을 새로 맺는 것은 비용이 큰 작업이므로, 요청마다 연결을 맺고 끊는 대신 풀에서 빌려 쓰고 반납한다. 풀의 연결이 모두 사용 중이면 새 요청은 연결이 반납될 때까지 기다리고, 정해진 시간(HikariCP의 connectionTimeout, 기본 30초) 안에 얻지 못하면 예외가 발생한다. Spring Boot의 기본 구현은 HikariCP다.
- OSIV(Open Session In View)
- 영속성 컨텍스트를 트랜잭션이 끝난 뒤에도 요청 처리가 끝날 때(응답 작성)까지 유지하는 설정이다. 컨트롤러나 뷰에서도 지연 로딩을 쓸 수 있어 편리하지만, 요청 처리 중 처음 DB에 접근한 뒤로는 응답이 끝날 때까지 DB 커넥션을 계속 붙잡고 있어 요청이 많은 서비스에서는 커넥션이 부족해질 수 있다. Spring Boot에서는 기본으로 켜져 있다.
- 영속성 컨텍스트
- 엔티티를 보관하고 관리하는 JPA의 논리적 공간이다. 조회하거나 저장한 엔티티는 이곳에 보관되며, JPA는 이곳의 엔티티에 일어난 변화를 추적해 트랜잭션이 끝날 때 DB에 반영한다. 보통 트랜잭션 하나와 생명주기를 같이한다(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다). 1차 캐시, 변경 감지, 쓰기 지연 같은 기능이 이 위에서 동작한다.
- 병목(Bottleneck)
- 시스템 전체의 처리 성능을 제한하는 가장 느린 부분이다. 다른 부분을 아무리 개선해도 병목이 그대로면 전체 성능은 나아지지 않는다. 성능 개선은 측정으로 병목을 찾는 것에서 시작한다.
이렇게 바꿔 보세요
- 처음 설정으로 — OSIV 켜짐. DB 작업은 0.5초인데 커넥션을 2.5초씩 쥐고 있습니다.
- OSIV를 끄면 — DB 작업이 끝나는 즉시 커넥션을 돌려줍니다.
- 요청이 더 몰리면 — OSIV 켜짐, 20건. 5초를 기다리다 실패하는 요청이 나옵니다.
- 풀을 늘리면 — 같은 20건에 풀 10개. 버티지만 DB가 받을 수 있는 연결 수에는 한계가 있습니다.
- 쿼리 자체가 느리면 — OSIV를 꺼도 DB 작업 2초 동안은 커넥션을 씁니다.
처음 설정으로 실행하면
- 0.0초: 요청 1번이 커넥션 1번을 빌려 DB 작업을 시작합니다.
- 0.1초: 요청 2번이 커넥션 2번을 빌려 DB 작업을 시작합니다.
- 0.2초: 요청 3번이 커넥션 3번을 빌려 DB 작업을 시작합니다.
- 0.3초: 요청 4 도착. 커넥션 3개가 모두 사용 중이라 기다립니다.
- 0.4초: 요청 5 도착. 커넥션 3개가 모두 사용 중이라 기다립니다.
- 0.5초: 요청 1번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 1번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 0.5초: 요청 6 도착. 커넥션 3개가 모두 사용 중이라 기다립니다.
- 0.6초: 요청 2번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 2번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 0.6초: 요청 7 도착. 커넥션 3개가 모두 사용 중이라 기다립니다.
- 0.7초: 요청 3번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 3번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 0.7초: 요청 8 도착. 커넥션 3개가 모두 사용 중이라 기다립니다.
- 2.5초: 요청 1 응답 완료. 이제 커넥션 1번을 돌려줍니다.
- 2.5초: 요청 4번이 커넥션 1번을 빌려 DB 작업을 시작합니다. 2.2초를 기다렸습니다.
- 2.6초: 요청 2 응답 완료. 이제 커넥션 2번을 돌려줍니다.
- 2.6초: 요청 5번이 커넥션 2번을 빌려 DB 작업을 시작합니다. 2.2초를 기다렸습니다.
- 2.7초: 요청 3 응답 완료. 이제 커넥션 3번을 돌려줍니다.
- 2.7초: 요청 6번이 커넥션 3번을 빌려 DB 작업을 시작합니다. 2.2초를 기다렸습니다.
- 3.0초: 요청 4번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 1번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 3.1초: 요청 5번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 2번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 3.2초: 요청 6번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 3번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 5.0초: 요청 4 응답 완료. 이제 커넥션 1번을 돌려줍니다.
- 5.0초: 요청 7번이 커넥션 1번을 빌려 DB 작업을 시작합니다. 4.4초를 기다렸습니다.
- 5.1초: 요청 5 응답 완료. 이제 커넥션 2번을 돌려줍니다.
- 5.1초: 요청 8번이 커넥션 2번을 빌려 DB 작업을 시작합니다. 4.4초를 기다렸습니다.
- 5.2초: 요청 6 응답 완료. 이제 커넥션 3번을 돌려줍니다.
- 5.5초: 요청 7번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 1번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 5.6초: 요청 8번의 DB 작업이 끝나고 외부 API 호출을 시작합니다. OSIV가 켜져 있어 커넥션 2번을 돌려주지 않고 응답이 끝날 때까지 쥐고 있습니다.
- 7.5초: 요청 7 응답 완료. 이제 커넥션 1번을 돌려줍니다.
- 7.6초: 요청 8 응답 완료. 이제 커넥션 2번을 돌려줍니다.
실행 결과
- OSIV가 켜져 있으면 영속성 컨텍스트가 응답이 끝날 때까지 열려 있고, 그동안 DB 커넥션도 함께 붙잡혀 있습니다. DB를 쓰지 않는 외부 API 대기 시간까지 커넥션을 점유하므로, 트래픽이 많은 API 서버에서는 커넥션이 금방 모자랍니다. 끄면 트랜잭션이 끝날 때 커넥션을 돌려줍니다.
코드
# application.yml
spring.datasource.hikari.maximum-pool-size: 3
spring.datasource.hikari.connection-timeout: 5000 # 5초 기다리다 실패
spring.jpa.open-in-view: true
// 컨트롤러
OrderDto get(long id) {
Order order = orderService.find(id); // @Transactional, DB 작업 0.5초
Delivery d = deliveryApi.track(order); // 외부 API 2초 (DB 안 씀)
return OrderDto.of(order, d);
}
이 모형의 전제
요청은 0.1초 간격으로 들어오고, 요청을 처리할 서버 스레드는 충분하다고 가정했습니다(톰캣 기본 200개). 커넥션을 얻지 못하고 기다리는 한도는 5초로 고정했습니다(HikariCP의 기본값은 30초입니다). OSIV가 켜져 있을 때 커넥션은 처음 DB에 접근하는 순간 빌려서 응답이 끝날 때 돌려준다고 보았고, 이것은 Hibernate가 스프링 환경에서 커넥션을 세션이 닫힐 때까지 유지하는 기본 동작에 따른 것입니다. 숫자는 관계를 보이기 위한 모형이며 실제 측정값이 아닙니다.
앞 실습: JdbcTemplate과 예외 변환 · 다음 실습: 서블릿