현상
본사와 유럽법인에서 따로 운영하던 결과 다운로드 서버를 점검하다가 DNA2에 DB 연결 232개가 유지된 기록을 발견했습니다. 숫자만 보면 Connection.close() 누락부터 의심하기 쉽지만, 개별 쿼리는 정상적으로 끝났고 눈에 띄는 반환 누락도 보이지 않았습니다.
여기서는 연결 하나가 풀로 돌아오지 않는 경우와, 연결을 소유한 풀 자체가 계속 새로 생기는 경우를 나눠 봐야 했습니다. 둘은 겉으로는 연결 수 증가처럼 보여도 고쳐야 할 위치가 전혀 다릅니다.
실행 환경과 변경 제약
- Java 8, Spring Boot 2.7.5, MyBatis와 복수 Oracle·MySQL 접근 경로를 사용했습니다.
- 본사 Model2와 유럽법인 Model1의 기존 URL·파라미터·파일명·압축 결과를 유지해야 했습니다.
- LIMS 2.0과 3.0 접근 경로, DB Link, 법인별 설정과 배포 방식이 섞여 있었습니다.
- 풀 관리 방식을 바꾸더라도 기존 다운로드 결과와 외부 호출 규격에 회귀가 없어야 했습니다.
봐야 할 곳은 연결 수만이 아니었습니다. DB 접근 객체가 어디에서 만들어지고 법인별 실행 구성이 어떻게 조립되는지도 함께 따라갔습니다.
처음 세운 가설
Connection이 많다는 현상만 보면 가장 먼저 close 누락을 의심할 수 있습니다. 하지만 다음 후보를 함께 놓고 확인했습니다.
- 예외 경로에서 Connection이나 SqlSession이 반환되지 않습니까?
- 하나의 Pool이 트래픽에 따라 많은 Connection을 보유하고 있습니까?
- Pool 자체가 요청이나 Query마다 새로 만들어집니까?
- DB Link와 복수 DB 접근이 예상보다 많은 물리 연결을 만듭니까?
- 배포 환경의 JDBC Driver나 서버 공용 설정이 애플리케이션 설정과 다릅니까?
이 중 어떤 문제인지에 따라 수정 범위가 달라집니다. close 누락이면 자원 반환 경로를 고쳐야 하지만, Pool 증식이면 객체 생명주기 자체를 바꿔야 합니다.
누수와 Pool 증식을 구분했습니다
- Connection이 사용 후 반환되지 않는 누수인지 먼저 확인했습니다.
- Pool 하나의 사용량이 커지는지, Pool의 개수 자체가 요청 또는 기능 단위로 늘어나는지 구분했습니다.
- SqlSessionFactory와 DataSource를 만드는 호출 경로를 요청 진입점부터 역추적했습니다.
- 설정 파일을 읽는 위치와 객체 생성 메서드가 몇 번 호출되는지 확인했습니다.
호출 경로를 따라가자 클라이언트 요청을 처리할 때 커넥션 풀 객체를 새로 만들고, 쿼리 호출 과정에서 SqlSessionFactory까지 다시 생성하는 구조가 확인됐습니다.
대표적인 레거시 쿼리 경로를 단순화하면 다음과 같습니다. MyBatis 설정에는 POOLED DataSource가 선언돼 있었고, connect()를 호출할 때마다 설정 파일을 다시 읽어 새 SqlSessionFactory를 조립했습니다. 아래 코드는 내부 오류 처리와 실제 Query ID만 덜어낸 형태입니다.
private void connect()
throws IOException {
Reader config =
Resources.getResourceAsReader(
"mybatis-config.xml"
);
sqlSessionFactory =
new SqlSessionFactoryBuilder()
.build(config);
}
public Result findResult(
Query query
) throws IOException {
SqlSession session = null;
try {
connect();
session = sqlSessionFactory
.openSession();
return session.selectOne(
"public.resultQuery",
query
);
} finally {
if (session != null) {
session.close();
}
}
}session.close()는 실행되고 있었습니다. 다만 Connection이 돌아가는 곳은 그 호출에서 막 만들어진 Pool이었습니다. 여러 레거시 모듈에서 같은 조립 방식이 반복됐고, 요청이 끝난 뒤 이 Factory와 Pool을 다시 쓸 애플리케이션 단위의 소유자는 없었습니다. 모든 쿼리가 똑같았다고 일반화할 수는 없지만, 적어도 확인한 경로에서는 자원 반환보다 객체 생명주기가 더 큰 문제였습니다.
쿼리가 끝나도 DB 연결이 줄지 않은 이유
Connection Pool은 매 요청마다 만드는 임시 객체가 아니라, 애플리케이션이 재사용하기 위한 장기 객체입니다. 일반적으로 Pool은 설정에 따라 최소·유휴 Connection을 유지하고, 연결 유효성 검사와 대기열을 관리합니다.
요청마다 Pool을 만들면 다음 문제가 겹칩니다.
- 여러 Pool이 각자의 유휴 Connection을 유지해 전체 연결 수가 증가합니다.
- Connection 재사용 범위가 요청 단위로 쪼개져 Pool을 사용하는 이점이 사라집니다.
- Pool별 상태와 대기열이 분산돼 운영 지표만으로 실제 사용량을 파악하기 어렵습니다.
- 종료되지 않은 Pool 객체와 관련 스레드·리소스가 남을 수 있습니다.
SqlSessionFactory도 Query마다 만들 객체가 아닙니다. MyBatis 설정, Mapper, TypeHandler와 Environment를 조립하는 비용이 큰 스레드 안전 공용 객체입니다. Query마다 다시 만들면 반복 초기화 비용뿐 아니라 어느 DataSource와 설정을 사용하는지 추적하기 어려워집니다.
원인은 Connection을 소유하는 객체가 요청마다 새로 만들어진 데 있었습니다.
DataSource와 SessionFactory를 애플리케이션이 관리하게 했습니다
DataSource와 SessionFactory의 생명주기를 애플리케이션 단위로 올리고, LIMS 2.0과 3.0처럼 분리가 필요한 접근 경로는 명시적인 복수 DataSource로 관리했습니다. 요청은 이미 준비된 SessionFactory에서 세션을 빌려 쓰고 반환하는 책임만 갖도록 정리했습니다. 법인별 Build Profile과 배포 흐름은 분리하되 다운로드 코드는 하나의 프로젝트로 통합했습니다.
개선 뒤에는 LIMS 2.0과 3.0이 각각 DataSource, SqlSessionFactory, SqlSessionTemplate Bean과 전용 @MapperScan을 가졌습니다. 아래 코드는 접속 정보와 내부 패키지명을 빼고 Bean의 소유 관계만 남긴 형태입니다. DataSource에는 Pool 크기, 검증 Query와 수명을 설정한 AtomikosDataSourceBean을 사용했습니다.
@Configuration
@MapperScan(
basePackages =
"example.repository.lims2",
sqlSessionTemplateRef =
"lims2SqlSessionTemplate"
)
class Lims2DataSourceConfig {
@Bean
DataSource lims2DataSource() {
AtomikosDataSourceBean pool =
new AtomikosDataSourceBean();
pool.setMinPoolSize(
minPoolSize
);
pool.setMaxPoolSize(
maxPoolSize
);
pool.setTestQuery(
validationQuery
);
pool.setMaxLifetime(
maxLifetimeSeconds
);
return pool;
}
@Bean
SqlSessionFactory
lims2SqlSessionFactory(
@Qualifier("lims2DataSource")
DataSource dataSource
) throws Exception {
SqlSessionFactoryBean factory =
new SqlSessionFactoryBean();
factory.setDataSource(
dataSource
);
return factory.getObject();
}
@Bean
SqlSessionTemplate
lims2SqlSessionTemplate(
@Qualifier(
"lims2SqlSessionFactory"
)
SqlSessionFactory factory
) {
return new SqlSessionTemplate(
factory
);
}
}LIMS 3.0도 별도의 설정 클래스에서 같은 세 빈을 만들고 전용 매퍼 패키지에 연결했습니다. 애플리케이션이 시작될 때 DB별 풀과 팩토리가 한 번 조립되고, 요청은 매번 새로 만드는 대신 해당 SqlSessionTemplate을 재사용합니다. 애플리케이션이 종료될 때는 Spring 컨테이너가 공용 객체와 풀도 함께 닫습니다.
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별 빈과 설정을 분리해 접근 경로와 운영 지표를 서로 대응시킬 수 있음
함께 발견한 런타임 변수
DB 연결 방식을 개선한 뒤에도 운영 환경에서는 구형 JDBC 드라이버가 먼저 로딩되는 현상이 나타났습니다. JVM 클래스 로딩 로그로 서버 공용 드라이버의 선로딩을 확인해, 빌드 의존성과 운영 서버에서 실제 로딩된 드라이버가 다름을 확인했습니다. 이 경험은 DB 연결 문제를 코드만으로 끝내지 않고 실제 배포 환경까지 확인해야 한다는 기준이 됐습니다.
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건 모두 통과했습니다. 본사·유럽법인 통합테스트는 별도로 수행했습니다. DB 연결 수뿐 아니라 기존 호출 규격과 결과 파일이 유지되는지도 확인했습니다.
운영 반영 결과
운영에서 보인 변화는 232개에서 42개였습니다. 개선 뒤 화면에는 LIMS 2.0과 3.0이 각각 21개씩, 총 42개로 표시됐습니다. 다만 232개와 42개는 같은 조건에서 동시에 재현한 실험값이 아니라 서로 다른 시점과 자료에서 확인한 값입니다. 따라서 190개·약 81.9% 감소는 흐름을 보여 주는 참고치로만 봐야 합니다.
이 변화는 Pool 생명주기 개선뿐 아니라 DB 접근 경로와 애플리케이션 구조를 함께 정리한 결과이므로 단일 코드 변경의 효과로 한정하지 않았습니다.
요청 코드와 공용 객체의 책임이 분리되면서 어느 DataSource와 SessionFactory가 Connection을 소유하는지 명확해졌습니다. 이후 연결 수가 늘어날 때도 Query의 close 누락, Pool 설정, DB별 트래픽을 서로 다른 원인 후보로 나눠 추적할 수 있는 구조가 됐습니다.
정리
DB 연결 수가 많을 때 close() 호출만 찾으면 Pool 증식 문제를 놓칠 수 있습니다. Connection이 반환되는지와 별개로, Connection을 소유한 Pool과 SessionFactory가 몇 번 만들어지고 언제 종료되는지를 확인해야 합니다.
요청마다 만들어지던 DataSource와 SessionFactory는 애플리케이션이 시작할 때 한 번 만들고 종료할 때 닫도록 바꿨습니다. 중요한 건 싱글턴이라는 이름이 아니라 재사용해야 할 인프라 객체의 수명을 요청보다 길게 잡고, 어느 DB를 소유하는지도 분명히 한 것입니다.