JDBC의 등장
JDBC가 표준화되기 전에는 벤더별 드라이버를 직접 다루거나, ODBC 같은 타 언어 기반 인터페이스를 우회해서 DB에 접근했다. 그로 인해 생기는 문제점으로는, 데이터베이스를 다른 종류로 변경하면 애플리케이션 서버에 개발된 데이터베이스 사용 코드도 함께 변경해야 한다는 점이 있다. 또한 개발자가 각각의 데이터베이스마다 커넥션 연결, SQL 전달, 그리고 그 결과를 응답받는 방법을 새로 학습해야 한다. 즉, 표준적인 방법으로 SQL 실행이 어렵다. 이런 배경 속에서 자바 개발자들이 DB를 일관되게 접근할 수 있도록 하는 표준이 필요하게 되었고, 그 결과로 등장한 것이 JDBC이다. JDBC는 자바에서 관계형 데이터베이스에 접근할 수 있도록 해주는 표준 API이다. 드라이버 로딩, 커넥션 생성, SQL 실행, 결과 조회, 커밋/롤백 등 모든 과정을 직접 제어할 수 있다.
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement("INSERT INTO users ...");
ps.executeUpdate();
conn.close();
자바 표준이므로 모든 RDBMS에서 동일한 방식으로 사용 가능하고, 세세한 제어가 가능하다는 장점이 있다.
JDBC의 한계와 커넥션 풀
하지만 JDBC를 사용하더라도 몇 가지 단점들이 있다.
- 커넥션을 직접 열고 닫아야 해서 같은 코드를 반복하게 되고 자원이 낭비된다.
- SQL 실행 및 예외 처리 코드가 장황하고 지저분하다.
- 트랜잭션도 직접 다뤄야 해서 서비스의 비즈니스 로직과 섞이기 쉽다.
즉, 개발자가 실수하기 쉬운 반복 작업이 많고, 성능 저하와 코드 품질 저하로 이어진다.
JDBC의 가장 큰 문제는 커넥션이 필요할 때마다 매번 DB 커넥션을 새로 만드는 데 시간이 많이 걸리고, 커넥션을 닫지 않으면 리소스 누수가 발생한다는 점이다. DB는 물론이고 애플리케이션 서버에서도 TCP/IP 커넥션을 새로 생성하기 위한 리소스를 매번 사용해야 한다. 또한 사용자가 애플리케이션을 사용할 때, SQL을 실행하는 시간뿐만 아니라 커넥션을 새로 만드는 시간이 추가되기 때문에 응답 속도에 영향을 준다.
이런 문제를 해결하기 위해 나온 것이 커넥션 풀이다. WAS를 실행할 때 미리 일정 개수의 DB 커넥션들을 만들어서 WAS의 커넥션 풀에 놓고, 요청이 오면 풀에서 커넥션을 꺼내 쓰며, 다 쓰면 커넥션을 종료 시키지 않은 채로 커넥션 풀에 반납한다. 커넥션이 필요할 때마다 일일이 생성하지 않고, 커넥션을 다 쓰고 반납할 때도 커넥션을 닫지 않고 반납하여 리소스를 절약할 수 있다. 대표적인 커넥션 풀 오픈 소스들은 commons-dbcp2, tomcat-jdbc pool, HikariCP 등이 있다.
DataSource란?
커넥션을 얻는 방법은 JDBC DriverManager를 사용하거나, 커넥션 풀을 사용하는 등 많은 방법이 있다. 만약 DriverManager를 사용하다가 커넥션 풀을 사용하는 방법으로 바뀐다면 여러 코드를 바꿔야 할 것이다. 이러한 어려움을 극복하기 위해 커넥션을 얻는 방법을 추상화한 인터페이스인 DataSource가 필요하다. 대부분의 커넥션 풀은 DataSource 인터페이스를 이미 구현해 두었기 때문에 어떤 커넥션 풀을 사용하든 DataSource 인터페이스에만 의존하도록 코드를 작성하면 된다. Spring에서는 DataSource를 빈으로 등록해 두면, 필요한 곳에서 주입받아 쉽게 사용할 수 있다. 이를 통해 애플리케이션의 커넥션 획득 방식이 변경되더라도 비즈니스 로직에는 영향을 주지 않게 된다. 참고로 스프링 부트를 사용하면 개발자가 DataSource를 직접 등록하지 않을 경우, 스프링 부트가 자동으로 DataSource를 만들어 주며 기본으로 생성되는 데이터 소스는 커넥션 풀을 제공하는 HikariDataSource이다.
트랜잭션 처리의 어려움과 스프링의 트랜잭션 추상화
JDBC로 트랜잭션을 다룰 때는 다음처럼 직접 작성해야 한다.
conn.setAutoCommit(false);
try {
conn.commit();
} catch (Exception e) {
conn.rollback();
}
만약 현재 서비스 계층이 트랜잭션을 사용하기 위해서 JDBC 기술에 의존하고 있다면, 향후 JDBC에서 JPA 같은 다른 데이터 접근 기술로 변경하면, 서비스 계층의 트랜잭션 관련 코드도 모두 함께 수정해야 한다. 구현 기술마다 트랜잭션을 사용하는 방법이 다르다.
예를 들면 JDBC에서의 con.setAutoCommit(false)가 JPA에선 transaction.begin()이다.
이러한 문제를 해결하려면 트랜잭션 기능을 추상화하면 된다. 스프링 트랜잭션 추상화의 핵심 인터페이스가 PlatformTransactionManager인데, 데이터 접근 기술에 따른 트랜잭션 구현체도 대부분 이미 만들어져 있어서 가져다 사용하기만 하면 된다.
TransactionStatus status = txManager.getTransaction(...);
try {
txManager.commit(status);
} catch (Exception e) {
txManager.rollback(status);
}
PlatformTransactionManager를 사용함으로써 코드 일관성이 증가하고 기술 교체도 유연하게 할 수 있게 된다.
트랜잭션 동기화
트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 한다. 같은 커넥션을 동기화하기 위해선 Service 계층에서 파라미터로 커넥션을 Repository 계층으로 전달하는 방법도 있지만, 코드가 지저분해지는 등 여러 가지 문제가 많다. 그래서 스프링은 트랜잭션이 시작되면, 현재 스레드와 커넥션을 묶어 둔다. 이를 트랜잭션 동기화라고 한다. 트랜잭션 동기화 매니저는 ThreadLocal을 사용해서 커넥션을 동기화해 주며, 트랜잭션 매니저가 이 트랜잭션 동기화 매니저를 내부에서 사용한다.
작동 방식을 간단하게 설명하면 다음과 같다.
- 트랜잭션 매니저는 DataSource를 통해 커넥션을 생성하고 트랜잭션을 시작한다.
- 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
- Repository는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.
- 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다. 커넥션 풀을 사용하는 경우 커넥션 풀에 반환한다.
결과적으로 트랜잭션 내 모든 DB 작업이 동일한 커넥션으로 처리되어 일관성이 보장된다.
@Transactional: 선언적 트랜잭션 관리
위 내용들만 가지고 코딩을 하면 서비스 계층에 비즈니스 로직뿐 아니라 트랜잭션 관련 코드까지 포함될 것이다. 비즈니스 로직과 트랜잭션 관련 로직이 한 곳에 있으면 코드를 유지 보수하기 어려워진다.
그래서 스프링은 AOP 기반으로 @Transactional을 제공하여 트랜잭션을 선언적으로 처리할 수 있게 한다.
@Transactional
public void saveOrder() {
orderRepository.save(...);
}
트랜잭션 프록시가 트랜잭션 관련 로직을 모두 가지고 가서 서비스 계층엔 순수한 비즈니스 로직만 남게 되고, 트랜잭션이 시작하게 되면 프록시가 서비스를 대신 호출하게 된다.
@Transactional을 사용함으로써 얻는 가장 큰 장점은 서비스 계층에서 트랜잭션 코드가 제거되어 비즈니스 로직에 집중 가능해진다는 점이다. 개발자는 트랜잭션이 필요한 곳에 @Transactional 애노테이션 하나만 추가하면 된다. 나머지는 스프링 트랜잭션 AOP가 자동으로 처리해 준다.
마치며
스프링은 여러 문제들을 추상화와 자동화를 통해 점차 해결하며 개선되어 왔고, 지금은 @Transactional 하나로 복잡한 트랜잭션 처리도 쉽게 구현할 수 있게 되었다.
JDBC부터 시작된 데이터베이스 연동의 복잡한 과정들은 스프링의 추상화와 선언적 트랜잭션 덕분에 훨씬 단순하고 일관되게 관리할 수 있게 되었다. 더 나아가 JPA와 같은 ORM 기술을 사용할 때도 이 기반 개념들은 중요한 바탕이 될 것이다.