현상
본사와 유럽법인에서 중복 운영되던 결과 다운로드 서버를 점검하던 중, 현행 분석 자료에서 DNA2가 DB Connection 232개를 유지한 상태를 확인했다. 이 수치만으로 장애를 확정하지 않고, 요청·Query마다 Pool과 SessionFactory가 생성되는지 조사하는 출발점으로 삼았다.
개별 Query는 정상 종료됐고 명백한 Connection.close() 누락도 바로 보이지 않았다. 요청 처리가 끝났는데도 전체 연결 수가 충분히 내려가지 않는 이유를 설명하려면 Connection 한 개가 반환되지 않는 문제와 Connection을 소유한 Pool이 계속 만들어지는 문제를 구분해야 했다.
실행 환경과 변경 제약
- Java 8, Spring Boot 2.7.5, MyBatis와 복수 Oracle·MySQL 접근 경로를 사용했다.
- 본사 Model2와 유럽법인 Model1의 기존 URL·파라미터·파일명·압축 결과를 유지해야 했다.
- LIMS 2.0과 3.0 접근 경로, DB Link, 법인별 설정과 배포 구조가 섞여 있었다.
- Pool 구조만 바꾸더라도 기존 다운로드 결과와 외부 호출 계약에 회귀가 없어야 했다.
따라서 연결 수만 줄이는 국소 수정이 아니라, DB 접근 객체의 생성 위치와 법인별 실행 구성을 함께 확인했다.
처음 세운 가설
Connection이 많다는 현상만 보면 가장 먼저 close 누락을 의심할 수 있다. 하지만 다음 후보를 함께 놓고 확인했다.
- 예외 경로에서 Connection이나 SqlSession이 반환되지 않는가?
- 하나의 Pool이 트래픽에 따라 많은 Connection을 보유하는가?
- Pool 자체가 요청이나 Query마다 새로 만들어지는가?
- DB Link와 복수 DB 접근이 예상보다 많은 물리 연결을 만드는가?
- 배포 환경의 JDBC Driver나 서버 공용 설정이 애플리케이션 설정과 다른가?
이 중 어떤 문제인지에 따라 수정 범위가 달라진다. close 누락이면 자원 반환 경로를 고쳐야 하지만, Pool 증식이면 객체 생명주기 자체를 바꿔야 한다.
누수와 Pool 증식을 구분했다
- Connection이 사용 후 반환되지 않는 누수인지 먼저 확인했다.
- Pool 하나의 사용량이 커지는지, Pool의 개수 자체가 요청 또는 기능 단위로 늘어나는지 구분했다.
- SqlSessionFactory와 DataSource를 만드는 호출 경로를 요청 진입점부터 역추적했다.
- 설정 파일을 읽는 위치와 객체 생성 메서드가 몇 번 호출되는지 확인했다.
호출 경로를 따라가자 Client 요청을 처리할 때 Connection Pool 객체를 새로 만들고, Query 호출 경로에서 SqlSessionFactory까지 다시 생성하는 구조가 확인됐다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
Client 요청
→ DB 설정 로드
→ DataSource / Connection Pool 생성
→ MyBatis 설정 로드
→ SqlSessionFactory 생성
→ SqlSession 획득
→ Query 실행
→ SqlSession 반환이 흐름에서는 마지막 SqlSession을 닫아도 문제가 끝나지 않는다. 세션이 반환한 Connection은 생성한 Pool로 돌아갈 뿐이고, Pool 객체가 애플리케이션 생명주기 동안 정리되지 않으면 그 Pool이 가진 물리 Connection도 남을 수 있다.
왜 Query가 끝나도 Connection이 줄지 않았나
Connection Pool은 매 요청마다 만드는 임시 객체가 아니라, 애플리케이션이 재사용하기 위한 장기 객체다. 일반적으로 Pool은 설정에 따라 최소·유휴 Connection을 유지하고, 연결 유효성 검사와 대기열을 관리한다.
요청마다 Pool을 만들면 다음 문제가 겹친다.
- 여러 Pool이 각자의 유휴 Connection을 유지해 전체 연결 수가 증가한다.
- Connection 재사용 범위가 요청 단위로 쪼개져 Pool을 사용하는 이점이 사라진다.
- Pool별 상태와 대기열이 분산돼 운영 지표만으로 실제 사용량을 파악하기 어렵다.
- 종료되지 않은 Pool 객체와 관련 스레드·리소스가 남을 수 있다.
SqlSessionFactory도 Query마다 만들 객체가 아니다. MyBatis 설정, Mapper, TypeHandler와 Environment를 조립하는 비용이 있는 스레드 안전한 공용 객체다. Query마다 다시 만들면 반복 초기화 비용뿐 아니라 어느 DataSource와 설정을 사용하는지 추적하기 어려워진다.
즉, 이 사례는 Connection을 닫지 않았다가 아니라 Connection을 소유하는 객체를 너무 많이 만들었다가 핵심이었다.
애플리케이션 단위 생명주기로 재설계
DataSource와 SessionFactory의 생명주기를 애플리케이션 단위로 올리고, LIMS 2.0과 3.0처럼 분리가 필요한 접근 경로는 명시적인 복수 DataSource로 관리했다. 요청은 이미 준비된 SessionFactory에서 세션을 빌려 쓰고 반환하는 책임만 갖도록 정리했다. 법인별 Build Profile과 배포 흐름은 분리하되 다운로드 코드는 하나의 프로젝트로 통합했다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
Application 시작
→ DB별 DataSource / Connection Pool 생성
→ DB별 SqlSessionFactory 생성
Client 요청
→ 필요한 SqlSessionFactory 선택
→ SqlSession 사용
→ Pool로 Connection 반환
Application 종료
→ Spring Container가 공용 객체와 Pool 종료LIMS 2.0과 3.0은 서로 다른 DataSource와 SessionFactory로 구분했다. 기존 DB Link를 통해 다른 DB를 우회 조회하던 구간도 직접 DataSource를 선택하는 구조로 바꿨다. 이렇게 해야 요청 코드가 어느 DB를 사용하는지 명시적으로 드러나고, DB Link 누락이나 환경별 객체 중복 생성을 줄일 수 있었다.
본사와 유럽법인은 설정 파일과 Build Profile, Jenkins 배포 흐름을 분리하되 애플리케이션 코드는 하나로 통합했다. 공용 객체의 생명주기를 올리면서도 법인별 접속 정보나 불필요한 설정이 다른 빌드에 섞이지 않도록 한 것이다.
변경 전후 책임 비교
- 변경 전: 요청 코드가 DB 설정 로드, Pool 생성, SessionFactory 생성, Query 실행까지 모두 담당
- 변경 후: Spring Container가 DataSource와 SessionFactory를 한 번 생성하고 종료까지 관리
- 변경 전: Query가 자신의 DB 접근 환경을 매번 구성
- 변경 후: Query는 명시된 SessionFactory를 선택해 세션을 빌리고 반환
- 변경 전: DB Link와 동적 객체 생성 때문에 실제 접근 DB와 Pool 수를 추적하기 어려움
- 변경 후: DB별 Bean과 설정을 분리해 접근 경로와 운영 지표를 대응 가능
함께 발견한 런타임 변수
구조 개선 후에도 운영 환경에서는 구형 JDBC Driver가 먼저 로딩되는 현상이 나타났다. JVM Class Loading 로그로 서버 공용 구형 Driver의 선로딩을 확인해 애플리케이션 의존성과 운영 런타임의 차이를 분리했다. 이 경험은 Connection 문제를 코드만으로 끝내지 않고 실제 배포 환경까지 확인해야 한다는 기준이 됐다.
Pool 구조와 JDBC Driver 충돌은 서로 다른 원인이었다. 전자는 과도한 Connection 수를 만든 객체 생명주기 문제이고, 후자는 MyBatis가 기대한 JDBC API와 실제 Driver 구현이 달라 발생한 실행 환경 문제였다. 한 프로젝트에서 연속해 발견됐지만 원인과 검증을 분리해 처리했다.
테스트 기준을 기존 업무 계약으로 잡았다
구조가 좋아졌다는 사실만으로 운영 반영할 수는 없었다. 다운로드 서버는 기존 LIMS·COMS와 메일 링크에서 사용하던 URL과 축약 파라미터, 파일명, 압축 구성까지 유지해야 했다.
- 기능별 요청 전후 Connection 수와 Pool 개수를 비교했다.
- 같은 요청을 반복 실행한 뒤 새로운 Pool이 계속 증가하지 않는지 확인했다.
- 본사·유럽법인의 서로 다른 DB 접근 경로를 각각 테스트했다.
- 기존 URL·파라미터·파일명·압축 결과와 오류 응답이 유지되는지 비교했다.
- 구형·신형 LIMS 직접 연결과 DB Link 제거 구간의 조회 결과를 대조했다.
- 단계별 배포 후 DB Connection 수, 로그, 대용량 다운로드를 모니터링했다.
기능테스트 20개 시나리오·총 557개 테스트 케이스를 수행했다. 최초 수행에서는 548건이 성공하고 9건이 실패했으며, 결함 수정 후 최종 557건 전체 통과를 확인했다. 본사·유럽법인 통합테스트는 별도 범위로 수행했으며 557개 수치와 연결하지 않는다. Connection 수치뿐 아니라 기존 호출 계약과 결과 파일이 유지되는지를 완료 기준으로 사용했다.
운영 반영 결과
개선 전 232개는 현행 분석 PPT의 직접 근거이고, 개선 후 42개는 당시 운영 화면을 LIMS 2.0 21개·LIMS 3.0 21개로 판독한 기록이다. 후자의 원본 캡처와 해시는 보존되지 않았다. 두 기록을 기준으로 190개·약 81.9% 감소를 계산하되, 동일 원본에 기반한 직접 실측으로 표현하지 않는다.
이 변화는 Pool 생명주기 개선뿐 아니라 DB 접근 경로와 애플리케이션 구조를 함께 정리한 결과이므로 단일 코드 변경의 효과로 한정하지 않았다.
요청 코드와 공용 객체의 책임이 분리되면서 어느 DataSource와 SessionFactory가 Connection을 소유하는지 명확해졌다. 이후 연결 수가 늘어날 때도 Query의 close 누락, Pool 설정, DB별 트래픽을 서로 다른 원인 후보로 나눠 추적할 수 있는 구조가 됐다.
정리
DB 연결 수가 많을 때 close() 호출만 찾으면 Pool 증식 문제를 놓칠 수 있다. Connection이 반환되는지와 별개로, Connection을 소유한 Pool과 SessionFactory가 몇 번 만들어지고 언제 종료되는지를 확인해야 한다.
이 사례에서는 요청마다 만들어지던 DataSource와 SessionFactory를 애플리케이션 공용 객체로 올리고, DB별 접근 경로를 명시적으로 분리했다. 핵심은 Singleton 자체가 아니라 재사용해야 하는 인프라 객체의 생명주기를 애플리케이션 생명주기와 맞춘 것이었다.