R2DBC MSSQL Connection Closed 에러 완벽 해결 가이드
서버 로그를 확인하다가 갑자기 숨이 턱 막히는 순간이 있습니다. 빨간색으로 가득 찬 스택 트레이스, 그중에서도 도무지 어디서부터 읽어야 할지 모를 정도로 긴 예외 이름을 마주했을 때죠. 아마 지금 이 글을 클릭하신 분들도 **io.r2dbc.mssql.client.reactornettyclient$mssqlconnectionclosedexception connection closed**라는, 이름부터 위압적인 에러 메시지와 씨름하고 계실 겁니다.
저도 처음 Spring WebFlux와 R2DBC를 도입하여 레거시 MSSQL 데이터베이스를 비동기로 전환하던 프로젝트에서 이 에러를 만났을 때 꽤나 골머리를 앓았습니다. 잘 돌아가던 서버가 밤사이 유령처럼 커넥션을 잃어버리고, 아침이면 500 에러를 뱉어내는 상황은 개발자에게 악몽과도 같습니다.
이 글에서는 단순히 "재시작하세요" 같은 뻔한 이야기는 하지 않겠습니다. 왜 하필 R2DBC와 MSSQL 조합에서 이 문제가 빈번한지, 그리고 내부적으로 Netty와 커넥션 풀(Pool) 사이에서 어떤 오해가 생기는지를 아주 깊이 있게, 그리고 제가 겪은 시행착오를 바탕으로 풀어보려 합니다. 이 글을 끝까지 읽으시면 더 이상 이 긴 예외 이름이 두렵지 않게 될 것입니다.
도대체 이 에러는 왜 발생하는가?
먼저 적을 알고 나를 알아야 백전백승입니다. 이 에러 메시지를 해부해 보면 답이 보입니다. 핵심은 ReactorNettyClient와 MssqlConnectionClosedException입니다.
쉽게 말해, **"애플리케이션(R2DBC 클라이언트)은 연결이 살아있다고 믿고 데이터를 보냈는데, 이미 네트워크 레벨(Netty)이나 데이터베이스 쪽에서는 문을 닫아버린 상태"**입니다.
보통 JDBC를 사용할 때는 히카리CP(HikariCP) 같은 성숙한 커넥션 풀이 알아서 죽은 연결을 쳐내고 새것을 가져다줍니다. 하지만 R2DBC 생태계, 특히 r2dbc-mssql과 r2dbc-pool의 조합은 아직 JDBC만큼 "알아서 잘 딱 깔끔하게" 처리해주지 못하는 경우가 있습니다.
제가 프로젝트를 진행하며 겪었던 주된 원인은 크게 두 가지였습니다.
DB 서버의 Idle Timeout과 애플리케이션 설정의 불일치: MSSQL 서버는 일정 시간 활동이 없는 연결을 강제로 끊습니다. 하지만 애플리케이션의 커넥션 풀은 이걸 모르고 있다가, 요청이 들어오면 "어? 나 커넥션 가지고 있는데?" 하고 죽은 커넥션을 내미는 것이죠.
네트워크 장비(로드밸런서/방화벽)의 개입: 클라우드 환경(Azure SQL, AWS RDS 등)을 쓸 때 자주 겪는 문제입니다. 중간에 있는 네트워크 장비가 유휴(Idle) TCP 연결을 조용히 끊어버립니다(Silent Drop).
경험담: 새벽 3시의 알림과 유령 커넥션
실제로 제가 겪었던 사례를 말씀드리겠습니다. 서비스 오픈 초기, 트래픽이 적은 새벽 시간대만 되면 간헐적으로 API 호출이 실패한다는 알림이 왔습니다. 로그를 까보면 영락없이 이 MssqlConnectionClosedException이 찍혀 있었죠.
재미있는 건, 트래픽이 몰리는 낮 시간대에는 아무런 문제가 없었다는 점입니다. 즉, 커넥션이 계속 사용될 때는 문제가 없다가, 오랫동안 사용되지 않고 풀(Pool)에서 놀고 있을 때 문제가 터진다는 강력한 증거였습니다.
당시 저는 R2DBC 설정에서 max-idle-time만 믿고 있었습니다. 하지만 실제 범인은 max-life-time 설정 누락과 validation query의 부재였습니다. 이 경험을 통해 R2DBC 설정은 단순히 "연결만 되면 끝"이 아니라, "생명 주기 관리"가 핵심이라는 것을 뼈저리게 느꼈습니다.
R2DBC vs JDBC: 커넥션 관리의 차이점
이 문제를 근본적으로 해결하려면 기존의 블로킹 방식인 JDBC와 리액티브 방식인 R2DBC가 커넥션을 어떻게 다르게 다루는지 이해해야 합니다. 많은 분들이 JDBC 설정을 그대로 머릿속에 그리고 접근하다가 낭패를 봅니다.
아래 표를 통해 두 기술의 커넥션 관리 차이를 명확히 비교해 보겠습니다.
표에서 보시듯, R2DBC는 성능을 위해 비동기로 작동하지만, 그만큼 연결 상태에 대한 "확신"을 가지는 비용이 듭니다. 그래서 우리는 설정을 통해 좀 더 보수적으로 커넥션을 관리해야 합니다.
해결 솔루션: application.yml 최적화
이제 실질적인 해결책을 적용해 볼 시간입니다. io.r2dbc.mssql.client.reactornettyclient$mssqlconnectionclosedexception을 방지하기 위한 가장 확실한 방법은 **"DB가 끊기 전에 내가 먼저 끊고 다시 맺는 것"**입니다.
Spring Boot application.yml (또는 .properties) 파일에서 아래 설정들을 점검하고 수정해 보세요.
1. max-life-time 설정 (가장 중요)
가장 강력한 해결책입니다. 커넥션이 생성된 후 얼마 동안 살려둘지를 결정합니다. 핵심은 데이터베이스나 네트워크 장비의 Timeout 시간보다 반드시 짧게 설정해야 한다는 점입니다.
예를 들어, MSSQL 서버나 Azure SQL의 기본 유휴 타임아웃이 30분이라면, 애플리케이션의 max-life-time은 25분 정도로 설정하는 것이 안전합니다. 저는 개인적으로 훨씬 짧게 잡는 것을 추천합니다.
spring:
r2dbc:
pool:
# 커넥션의 최대 수명. DB 서버 설정보다 짧아야 함.
# 예: 10~30분 추천 (단위 확인 필요, 보통 ms 또는 Duration 포맷)
max-life-time: 600000 # 10분
# 풀에 유휴 상태로 남을 수 있는 최대 시간
max-idle-time: 300000 # 5분
2. Validation Query (유효성 검사) 추가
커넥션을 풀에서 꺼내서 사용하기 전에 "야, 너 살아있니?"라고 찔러보는 과정입니다. 성능 저하를 우려하는 분들도 계시지만, SELECT 1 같은 가벼운 쿼리는 R2DBC의 비동기 환경에서 거의 비용이 들지 않습니다. 안정성을 위해 반드시 켜두세요.
spring:
r2dbc:
pool:
# 커넥션을 빌려줄 때마다 검사할지 여부
validation-query: "SELECT 1"
validation-depth: REMOTE # 실제 DB까지 쿼리를 날려서 확인
최근 r2dbc-mssql 버전에서는 validation-depth를 설정하지 않으면 기본적으로 로컬 체크만 수행할 수 있으니, 확실하게 REMOTE로 설정하거나 쿼리를 명시하는 것이 좋습니다.
3. 커넥션 획득 타임아웃 조정
만약 풀이 꽉 찼거나, 죽은 커넥션을 정리하느라 새 커넥션을 맺는 데 시간이 걸릴 수 있습니다. 이때 무한정 기다리지 않도록 타임아웃을 적절히 설정해야 합니다.
spring:
r2dbc:
pool:
max-acquire-time: 5000 # 5초 안에 커넥션 못 얻으면 에러
심화: Netty 레벨의 TCP KeepAlive
만약 위의 풀 설정을 모두 적용했는데도 문제가 해결되지 않는다면, 네트워크 레벨을 의심해야 합니다. 특히 클라우드 환경이나 방화벽 뒤에 있는 DB라면 TCP KeepAlive 설정이 필수적입니다.
중간에 있는 방화벽은 패킷이 오가지 않는 연결을 "죽었다"고 판단하고 테이블에서 지워버립니다. 이를 방지하기 위해 OS나 Netty가 주기적으로 "나 아직 있어"라는 신호(KeepAlive 패킷)를 보내게 해야 합니다.
Spring Boot에서 R2DBC URL 파라미터나 ConnectionFactoryOptions 커스터마이징을 통해 이 옵션을 활성화할 수 있습니다.
// Java Config 예시 (ConnectionFactory 커스터마이징)
.option(Option.valueOf("socketKeepAlive"), true)
사실, 제가 겪었던 가장 까다로운 케이스도 결국은 이 TCP KeepAlive 문제였습니다. 애플리케이션 설정만 주구장창 바꾸다가, 인프라 팀과 함께 패킷을 캡처해 보고 나서야 4분마다 방화벽이 연결을 끊고 있다는 걸 발견했거든요.
마무리하며: 두려워 말고 리액티브하게 나아가세요
io.r2dbc.mssql.client.reactornettyclient$mssqlconnectionclosedexception 에러는 R2DBC를 처음 도입하는 팀이라면 한 번쯤 겪는 통과의례와도 같습니다.
이 에러가 발생했다고 해서 "역시 R2DBC는 불안정해, 다시 JDBC로 돌아가자"라고 성급하게 결론 내리지 않으셨으면 좋겠습니다. 이것은 기술의 결함이라기보다는, 비동기 논블로킹 방식이 물리적인 네트워크 연결을 다루는 방식의 특성에서 오는 설정의 미스매치일 뿐입니다.
오늘 당장 확인해보세요:
max-life-time이 설정되어 있는가? (없다면 기본값은 무한대일 수 있습니다)그 시간이 DB 서버의 타임아웃보다 짧은가?
validation-query가SELECT 1로 명시되어 있는가?
이 세 가지만 체크하셔도 빨간색 에러 로그의 90%는 사라질 것입니다. 리액티브 프로그래밍이 주는 높은 처리량과 효율성은 이러한 초기 설정의 고통을 감수할 만큼 충분히 가치가 있습니다. 여러분의 서버가 무중단으로 쌩쌩 돌아가는 그날까지, 이 글이 작은 팁이 되었기를 바랍니다.
Post a Comment