회사에서 이번에 맡은 프로젝트 배포 중 운영서버 배포 후 대시보드 화면을 반복해서 새로고침하면 페이지 전체가 잠깐 응답하지 않는 현상이 생겼다. DB가 죽은 것도 아니고, 애플리케이션이 다운된 것도 아니었는데 무슨 문제일까 천천히 찾아보다가 원인으로 추정되는 문제들을 하나씩 뜯어봤고 원인이 하나가 아닌걸 알아버렸다... 그리고 이런 저런 방법들을 사용해보고 결국 해결했는데 겸사겸사 느낀점이나 관련 정보들을 정리하려고 글을 작성한다.
새로고침 한 번이 요청 하나가 아니다
브라우저에서 새로고침을 누르면 HTML 파일 하나만 다시 받을 것 같지만, 실제로는 다르다.
새로고침
-> HTML
-> CSS 파일들
-> JS 파일들
-> 이미지/아이콘
-> 로그인 알림 API
-> 대시보드 데이터 API (여러 개)
한 번의 새로고침이 수십 개의 요청을 동시에 발생시킨다. 그리고 이 요청들이 전부 서버를 통과한다.
문제는 새로고침을 여러 번 누를 때다. 브라우저 입장에서는 이전 요청이 취소된 것처럼 보이지만, 서버에서는 이미 시작된 DB 쿼리나 세션 조회가 계속 진행된다. 그래서 새로고침을 누를수록 서버 내부에 처리 중인 요청이 계속 쌓인다.
왜 Redis Session을 쓰게 됐나
처음부터 Redis Session을 쓰려던 건 아니었다. 원래는 단일 서버에 HTTP Session만 쓰면 충분했는데, 프로젝트 구조상 로그인 서버를 별도로 분리해야 하는 상황이 됐다. 로그인 서버와 업무 서버가 나뉘면 둘 사이에 세션을 공유할 방법이 필요해진다.
선택지는 세 가지였다.
방식 내용 문제점
| JWT 전환 | 토큰 자체에 인증 정보 포함, 서버 간 공유 불필요 | 인증 구조 전면 재작성 필요, 영향 범위 큼 |
| Spring Session JDBC | DB에 세션 저장해서 서버 간 공유 | DB 부하 증가, Redis보다 느림 |
| Spring Session Redis | Redis에 세션 저장해서 서버 간 공유 | 기존 세션 코드 거의 그대로 유지 가능 |
JWT로 전환하면 인증 구조를 전면 재작성해야 해서 리스크가 컸다. Spring Session JDBC는 세션 조회가 DB를 타기 때문에 부하 측면에서 Redis보다 불리했다. Spring Session Redis는 기존 세션 기반 코드를 거의 손대지 않고 Redis만 공유 저장소로 추가하면 됐다. 그래서 Redis Session을 선택했다.
근데 이 선택이 나중에 병목의 원인 중 하나가 됐다.
모든 요청이 Redis를 타고 있었다
Spring Security는 기본적으로 모든 HTTP 요청에 대해 보안 필터 체인을 실행한다. 그리고 Spring Session Redis를 사용하면 인증이 필요한 요청마다 Redis에서 세션을 조회하고 SecurityContext를 복원한다.
요청 들어옴
-> Spring Security Filter Chain 실행
-> Redis에서 세션 조회
-> SecurityContext 복원
-> 컨트롤러 진입
이 흐름 자체는 문제가 없다. 문제는 CSS 파일, JS 파일, 이미지도 같은 필터 체인을 타고 있었다는 점이다.
정적 리소스는 인증이 필요 없다. 누가 요청하든 같은 파일을 내려주면 된다. 근데 Security 설정에서 정적 리소스 경로를 명시적으로 제외하지 않으면, JS 파일 하나를 받는 데도 Redis 세션 조회가 붙는다.
새로고침 한 번에 정적 파일 요청이 수십 개 발생하고, 그게 전부 Redis를 타고 있었다. Redis 서버가 처리해야 할 세션 조회 횟수가 API 요청과는 비교도 안 되게 많았던 것이다.
해결은 단순했다. Security 설정에서 정적 리소스 경로를 필터 대상에서 아예 제외했다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers("/js/**", "/css/**", "/images/**");
}
permitAll()이랑 다르다. permitAll()은 필터 체인은 그대로 타면서 요청을 허용하는 거라 Redis Session 조회가 여전히 붙는다. web.ignoring()은 필터 체인 자체를 건너뛰어서 Redis Session 조회가 아예 발생하지 않는다. 정적 리소스는 인증이 필요 없으니까 필터를 탈 이유가 없다.
이것만으로 Redis 세션 조회 횟수가 크게 줄었다.
Redis가 느린 게 아니라 너무 자주 타고 있었다
처음엔 Redis가 병목이라고 의심했다. 세션 조회가 너무 많으니 Redis가 느려졌을 거라고 봤다.
그래서 Redis 상태를 직접 확인했다.
connected_clients: 5
blocked_clients: 0
SLOWLOG: empty
Redis 서버 자체는 멀쩡했다. 처리 중인 슬로우 커맨드도 없고, 블로킹된 클라이언트도 없었다.
여기서 중요한 구분이 있다. Redis 서버가 느린 것과 Redis를 너무 자주 타는 구조가 병목인 것은 다르다. Redis가 아무리 빠른 인메모리 저장소라도, 인증 요청 수가 폭증하면 앱 쪽 Lettuce 커넥션 풀이 먼저 바닥난다.
Redis 서버 증설이 아니라 앱 구조와 Lettuce pool 설정을 손봐야 하는 이유였다.
Lettuce는 Spring에서 Redis에 접근할 때 쓰는 클라이언트인데, 여기도 커넥션 풀이 있다. Redis 서버가 아무리 여유 있어도 앱 쪽 Lettuce pool이 작으면 Redis 요청이 몰릴 때 앱 내부에서 pool 대기가 생긴다. 그래서 pool 크기를 늘리고 timeout을 줄였다.
중요한 건 이게 HikariCP 문제와 별개가 아니라는 점이다. 요청 하나가 Redis Session 조회와 DB 쿼리를 모두 거친다. 둘 중 하나만 막혀도 그 요청을 처리하던 Tomcat 스레드가 묶인다. 그래서 어느 한쪽만 고쳐서는 완전히 해소가 안 됐던 것이다.
DB 커넥션 풀이 빠르게 고갈되는 이유
정적 리소스 문제를 해결해도 대시보드 API들은 여전히 DB를 사용한다. 여기서 HikariCP 커넥션 풀 구조를 봐야 한다.
DB에 쿼리를 날리려면 커넥션을 먼저 확보해야 한다. 매 요청마다 커넥션을 새로 만들면 TCP 핸드셰이크, DB 인증, 세션 초기화 과정이 반복되니까 커넥션 풀을 미리 만들어두고 빌려 쓰는 방식을 쓴다.
핵심은 풀 크기가 동시에 DB를 사용할 수 있는 요청 수의 상한을 결정한다는 것이다.
당시 커넥션 풀이 너무 작아서 새로고침이 반복되면서 대시보드 API 요청이 몰리면 풀이 금방 소진됐다. 나머지 요청들은 커넥션이 날 때까지 기다리며 스레드를 점유한 채로 쌓였다.
동시 요청 N개가 DB 커넥션 요청
-> 풀 크기만큼만 커넥션 확보 후 쿼리 실행
-> 나머지는 커넥션 풀 앞에서 대기
connection-timeout: 30초
-> 대기 중인 스레드들이 30초 동안 살아서 자리를 차지
-> 그 사이 새 요청이 들어와도 처리할 스레드가 줄어든 상태
-> 사용자 입장에서 서버가 멈춘 것처럼 보임
커넥션 풀 크기를 보수적으로 늘린 이유
풀이 작다는 건 확실했다. 근데 바로 크게 올리지 않고 서버 상황 보면서 테스트해가며 늘렸다.
DB가 애플리케이션 서버 외부에 별도 서버로 있었기 때문이다. 풀을 크게 올리면 앱 서버 내부 대기는 줄어들지만, 그만큼 더 많은 동시 쿼리가 외부 DB 서버로 밀려든다. 앱 서버의 병목이 DB 서버 장애로 이동하는 것이다. PostgreSQL의 pg_stat_activity로 active connection, lock wait 여부를 보면서 조금씩 올리는 게 맞는 접근이었다.
connection-timeout을 30초에서 10초로 줄인 이유
커넥션을 기다리는 최대 시간이 30초면, 풀이 고갈됐을 때 요청 스레드들이 30초 동안 살아서 Tomcat 스레드를 점유한다. 새 요청이 들어와도 처리할 스레드가 줄어드는 구조다.
10초로 줄이면 병목이 생겼을 때 더 빨리 실패한다. 장애를 오래 숨기는 것보다 빨리 드러내는 것이 서버 전체를 보호하는 방향이다. 30초를 매달리는 것보다 10초 안에 실패하고 상위에서 빠르게 복구 흐름을 타는 편이 낫다.
커넥션 풀 크기를 공식으로 계산하면 안 되는 이유
커넥션 풀 크기를 정할 때 자주 인용되는 공식이 있다.
pool size = Tn × (Cm - 1) + 1
Tn: 동시에 DB를 사용하는 최대 스레드 수
Cm: 스레드 하나가 동시에 잡을 수 있는 최대 커넥션 수
근데 이 공식은 데드락을 피하기 위한 최솟값에 가깝다. 일반적인 Spring + MyBatis 구조에서는 요청 하나가 커넥션을 동시에 여러 개 잡는 경우가 거의 없으니 Cm = 1이고, 그러면 공식상 최솟값은 굉장히 작게 나온다.
이번 사례에서 공식보다 중요했던 건 요청 흐름이었다. 새로고침 한 번이 요청 하나가 아니었고, 각 요청이 Redis Session과 DB를 모두 사용했다. 공식에는 이런 맥락이 없다.
운영에서 풀 크기를 정할 때 같이 봐야 하는 게 있다. 한 화면이 발생시키는 API 수, 각 API가 DB를 점유하는 시간, DB가 외부 서버인지 여부, Redis 같은 공통 앞단 병목까지. 공식은 참고값이고 실제로는 운영 요청 패턴을 보고 잡아야 한다.
정리
원인은 한 계층이 아니었다. 정적 리소스가 Security 필터를 타면서 Redis Session 조회가 폭증했고, 거기에 커넥션 풀 부족으로 DB 대기 요청까지 쌓이면서 증상이 겹쳤다. 어느 하나만 고쳐서는 완전히 해소가 안 됐던 이유가 여기 있었다.
'Spring boot' 카테고리의 다른 글
| [Spring] Spring IoC 와 DI 개념정리 (1) | 2026.04.15 |
|---|---|
| Spring boot와 Maria DB 연동 (0) | 2025.04.23 |
