이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Timeout은 왜 계층별로 맞춰야 하는가?
- connection timeout, query timeout, transaction timeout, HTTP timeout은 어떻게 다르게 봐야 하는가?
- timeout을 늘리는 대응이 왜 위험할 수 있는가?
개요
Timeout은 시스템을 포기하게 만드는 장치다. timeout이 없거나 너무 길면 장애가 늦게 드러나고, 요청과 자원이 계속 쌓인다. 반대로 너무 짧으면 정상 작업도 실패한다.
백엔드 DB 작업에서 timeout은 여러 층에 있다.
- client timeout
- gateway/load balancer timeout
- application request timeout
- connection pool acquisition timeout
- transaction timeout
- query timeout
- database statement timeout 또는 lock timeout
이 값들이 서로 어긋나면 클라이언트는 이미 포기했는데 애플리케이션과 DB는 계속 일하거나, DB는 오래 일하는데 gateway가 먼저 끊는 상황이 생긴다.
원리
예를 들어 다음처럼 설정되어 있다고 하자.
Gateway timeout: 30s
Application HTTP timeout: 60s
Hikari connectionTimeout: 30s
DB statement timeout: 없음DB 쿼리가 2분 동안 실행되면 gateway는 30초에 클라이언트 연결을 끊을 수 있다. 하지만 애플리케이션과 DB는 계속 작업할 수 있다. 사용자는 실패를 받았는데 DB는 부하를 계속 만드는 나쁜 상황이다.
더 나은 설계는 바깥 계층보다 안쪽 계층의 작업 제한을 명확히 두는 것이다.
Gateway timeout: 30s
Application request budget: 25s
Transaction timeout: 20s
DB statement timeout: 15s
Hikari connectionTimeout: 2s숫자는 예시일 뿐이다. 핵심은 실패 순서를 의도적으로 설계하는 것이다.
실패 순서가 의도되어 있으면 장애 때 판단이 빨라진다. 예를 들어 DB statement timeout이 15초인데 gateway가 10초라면 사용자는 이미 실패했는데 DB가 5초 더 일할 수 있다. 반대로 connection timeout이 너무 짧으면 순간적인 작은 pool 대기에도 정상 요청이 실패할 수 있다.
Connection Timeout
HikariCP의 connectionTimeout은 SQL 실행 시간이 아니라 커넥션을 빌리기 위해 기다리는 시간이다.
spring.datasource.hikari.connection-timeout=2000이 값이 길면 풀 고갈 상황에서 요청 스레드가 오래 대기한다. 사용자는 응답을 오래 기다리고, 서버 스레드는 쌓이며, 장애가 전체 API로 번질 수 있다.
Query Timeout
JDBC나 JPA 쿼리 timeout은 개별 SQL 실행 시간을 제한한다.
@QueryHints(@QueryHint(name = "jakarta.persistence.query.timeout", value = "3000"))
List<Order> findRecentOrders();DB와 드라이버별 동작 차이가 있을 수 있다. 쿼리 timeout은 느린 쿼리를 영원히 방치하지 않게 해주지만, 근본 해결은 실행 계획과 인덱스, 쿼리 구조를 고치는 것이다.
PostgreSQL에서는 세션 또는 트랜잭션 단위로 statement_timeout을 둘 수 있다.
SET statement_timeout = '3s';Lock 대기만 제한하고 싶다면 DB별 lock timeout 설정을 검토한다.
PostgreSQL에서는 요청 단위로 다음처럼 둘 수 있다.
SET LOCAL statement_timeout = '3s';
SET LOCAL lock_timeout = '500ms';statement_timeout은 statement 전체 실행 시간을 제한하고, lock_timeout은 lock 획득 대기를 제한한다. lock 경합이 잦은 API에서는 둘을 분리해서 보아야 원인과 대응이 선명해진다.
Transaction Timeout
Spring에서는 트랜잭션 timeout을 지정할 수 있다.
@Transactional(timeout = 5)
public void cancelOrder(Long orderId) {
Order order = orderRepository.findByIdForUpdate(orderId)
.orElseThrow();
order.cancel();
}트랜잭션 timeout은 트랜잭션이 너무 오래 열려 락과 커넥션을 붙잡는 상황을 줄이는 데 도움을 준다. 그러나 timeout이 났을 때 재시도해도 되는 작업인지, 부분 부작용은 없는지 설계가 필요하다.
실전 팁
- 사용자-facing API와 batch 작업 timeout을 다르게 둔다.
- connection acquire timeout은 짧게 두어 풀 고갈을 빨리 드러낸다.
- DB statement timeout은 API SLA보다 짧게 둔다.
- lock timeout은 동시성 경합이 있는 API에서 별도로 검토한다.
- timeout 예외는 관측 가능해야 하며, 어떤 계층에서 난 timeout인지 로그에 남겨야 한다.
- timeout 로그에는 계층을 태깅한다. 예를 들어
POOL_ACQUIRE_TIMEOUT,QUERY_TIMEOUT,LOCK_TIMEOUT,HTTP_CLIENT_TIMEOUT처럼 나누면 대응이 빨라진다. - batch와 사용자 API는 timeout budget이 다르다. batch는 오래 걸릴 수 있지만 사용자 pool과 lock을 오래 잡지 않게 설계한다.
- timeout을 줄인 뒤에는 실패율뿐 아니라 DB active session과 pool pending이 줄었는지도 본다.
하지 말아야 할 대응
Timeout이 발생했다고 바로 값을 늘리면 안 된다. timeout은 원인을 알려주는 증상일 수 있다.
예를 들어 connection timeout이 난다면 다음을 먼저 본다.
- active connection이 계속 maximum인가?
- slow query가 있는가?
- lock wait가 있는가?
- 외부 API 호출이 트랜잭션 안에 있는가?
- connection leak가 있는가?
query timeout이 난다면 실행 계획과 rows를 본다. lock timeout이면 누가 락을 잡고 있는지 봐야 한다.
위험 신호!
- 모든 timeout을 60초 이상으로 길게 둔다.
- gateway timeout보다 DB statement timeout이 훨씬 길다.
- timeout 로그에 endpoint, SQL, transaction id, trace id가 없다.
- timeout 발생률이 증가하는데 알림이 없다.
- timeout을 늘리는 변경만 하고 실행 계획이나 락을 확인하지 않는다.
- 모든 timeout을 같은 값으로 맞춘다.
- timeout 예외가 어느 계층에서 났는지 metric label이 없다.
- lock timeout과 statement timeout을 구분하지 않아 동시성 경합과 느린 쿼리를 섞어 본다.
확인 질문
- Timeout 계층을 맞춘다는 것은 무엇인가?
- 클라이언트, gateway, 애플리케이션, 트랜잭션, 쿼리, DB 제한 시간이 서로 모순되지 않도록 실패 순서와 자원 해제 시점을 설계하는 것이다.
- connection timeout과 query timeout의 차이는 무엇인가?
- connection timeout은 풀에서 커넥션을 기다리는 시간 제한이고, query timeout은 SQL 실행 시간 제한이다.
- timeout을 무작정 늘리면 왜 위험한가?
- 자원이 더 오래 묶여 장애 감지가 늦어지고, 풀 고갈과 스레드 고갈이 더 크게 번질 수 있기 때문이다.
- lock timeout과 statement timeout을 구분해야 하는 이유는 무엇인가?
- lock timeout은 잠금 대기를 빨리 포기하게 하고, statement timeout은 SQL 전체 실행 시간을 제한하므로 원인과 대응이 다르기 때문이다.