공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

서비스 계층에는 원래 핵심 비즈니스 로직만 있어야 한다. 그런데 트랜잭션을 적용하는 순간 JDBC 기술이 서비스 계층으로 흘러 들어와 코드가 지저분해진다. 스프링은 이 문제를 트랜잭션 추상화, 트랜잭션 동기화, 트랜잭션 템플릿, 트랜잭션 AOP 순서로 하나씩 해결한다. 그 과정을 따라가면서, 최종적으로 서비스에 @Transactional 한 줄만 남기는 것이 이번 글의 목표다.
애플리케이션 구조
가장 단순하면서 많이 사용하는 구조는 역할에 따라 3가지 계층으로 나누는 것이다.
[@Controller] ─→ [@Service] ─→ [@Repository] ─→ [ DB ]
프레젠테이션 서비스 데이터 접근
UI/웹/검증 비즈니스 로직 JDBC/JPA 등
- 프레젠테이션 계층: UI와 웹 요청·응답, 사용자 요청 검증을 담당한다. 서블릿과 HTTP, 스프링 MVC를 사용한다.
- 서비스 계층: 핵심 비즈니스 로직을 담당한다. 가급적 특정 기술에 의존하지 않고 순수 자바 코드로 작성한다.
- 데이터 접근 계층: 실제 데이터베이스에 접근한다. JDBC, JPA, Redis 등을 사용한다.
이 중 가장 중요한 곳은 서비스 계층이다. 시간이 흘러 UI 기술이 바뀌거나 데이터 저장 기술을 다른 것으로 변경해도, 비즈니스 로직은 최대한 그대로 유지되어야 하기 때문이다. 그래서 기술에 종속적인 부분은 프레젠테이션 계층과 데이터 접근 계층이 맡고, 서비스 계층은 순수하게 남긴다. 데이터 접근 계층도 인터페이스로 제공하면 서비스 코드를 건드리지 않고 구현 기술을 바꿀 수 있다. 결국 계층을 나누는 핵심 목적은 서비스 계층을 순수하게 지키는 데 있다.
기존 코드의 문제점
트랜잭션을 적용하기 전의 서비스는 순수한 비즈니스 로직만 담고 있다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
계좌이체라는 비즈니스 로직만 보이고 특정 기술의 흔적이 거의 없다. 문제는 여기에 트랜잭션을 적용하면서 시작된다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); // 트랜잭션 시작
bizLogic(con, fromId, toId, money); // 커넥션을 파라미터로 전달
con.commit(); // 성공 시 커밋
} catch (Exception e) {
con.rollback(); // 실패 시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}
트랜잭션을 시작하고 커밋·롤백하기 위해 DataSource, Connection, SQLException 같은 JDBC 기술에 의존하게 됐다. 순수했던 서비스에 기술 코드가 파고들었고, 비즈니스 로직보다 트랜잭션을 처리하는 코드가 오히려 더 많아졌다. 정리하면 지금 코드의 문제는 크게 3가지다.
- 트랜잭션 문제: 트랜잭션을 적용하기 위해 JDBC 구현 기술이 서비스 계층에 누수된다. 같은 트랜잭션을 유지하려고 커넥션을 파라미터로 넘겨야 하고, try, catch, finally 가 반복된다.
- 예외 누수 문제: 데이터 접근 계층의 SQLException 이 서비스 계층으로 전파된다. JDBC 전용 예외라, 나중에 JPA 등 다른 기술로 바꾸면 예외도 함께 바뀌어 서비스 코드를 수정해야 한다.
- JDBC 반복 문제: 커넥션을 열고, PreparedStatement 를 사용하고, 결과를 매핑하고, 리소스를 정리하는 비슷한 코드가 계속 반복된다.
이번 글에서는 이 중 트랜잭션 문제를 스프링으로 해결한다. 예외 누수와 JDBC 반복 문제는 이어지는 글에서 다룬다.
트랜잭션 추상화
트랜잭션 문제의 뿌리는 구현 기술마다 트랜잭션을 사용하는 방법이 다르다는 데 있다. JDBC는 con.setAutoCommit(false) 로 시작하고, JPA는 transaction.begin() 으로 시작한다. 그래서 JDBC 트랜잭션에 의존하는 코드를 짜두면, 나중에 JPA로 바꿀 때 서비스 계층의 트랜잭션 코드를 전부 함께 고쳐야 한다.
이 문제는 트랜잭션 기능을 인터페이스로 추상화하면 해결된다. 트랜잭션이 하는 일은 결국 시작하고, 성공하면 커밋, 실패하면 롤백하는 것이 전부이기 때문이다.
public interface TxManager {
begin();
commit();
rollback();
}
이 인터페이스를 기준으로 JDBC용, JPA용 구현체를 각각 만들어두면, 서비스는 구현체가 아니라 인터페이스에만 의존하고 필요한 구현체를 DI로 주입받는다. 인터페이스에 의존하고 DI를 사용한 덕분에 OCP를 지키게 된다.
스프링은 이 고민을 이미 해결해두었다. 스프링 트랜잭션 추상화의 핵심이 바로 PlatformTransactionManager 인터페이스다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(TransactionDefinition definition);
void commit(TransactionStatus status);
void rollback(TransactionStatus status);
}
인터페이스는 트랜잭션을 시작하고, 커밋하고, 롤백하는 세 가지 메서드로 이뤄진다. getTransaction() 은 트랜잭션을 시작하는데, 이름이 get인 이유는 이미 진행 중인 트랜잭션이 있으면 새로 시작하지 않고 그 트랜잭션에 참여할 수 있기 때문이다. 그리고 트랜잭션이 정상적으로 끝나면 commit() 으로 커밋하고, 문제가 생기면 rollback() 으로 롤백한다.
서비스는 이 인터페이스에만 의존하면 되고, 실제 구현체는 사용하는 기술에 맞게 주입한다. JDBC를 쓰면 DataSourceTransactionManager, JPA를 쓰면 JpaTransactionManager 를 주입하면 되는데, 이런 구현체는 스프링이 대부분 만들어 두었다. 참고로 스프링 5.3부터는 JDBC용으로 DataSourceTransactionManager 를 확장한 JdbcTransactionManager 를 제공하는데, 둘의 차이는 크지 않아 같은 것으로 봐도 된다.
이렇게 추상화로 기술 종속 문제는 해결했다. 그런데 트랜잭션 매니저에게는 또 하나의 중요한 역할이 남아있다.
트랜잭션 동기화
스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다. 하나는 앞에서 본 트랜잭션 추상화이고, 다른 하나가 지금 볼 리소스 동기화다.
트랜잭션을 유지하려면 시작부터 끝까지 같은 데이터베이스 커넥션을 써야 한다. 앞의 코드에서는 이 커넥션을 맞추기 위해 파라미터로 커넥션을 계속 넘겼다. 하지만 이 방식은 코드가 지저분해지는 것은 물론이고, 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야 하는 단점이 있다.
스프링은 트랜잭션 동기화 매니저로 이 문제를 해결한다. 동기화 매니저는 쓰레드 로컬(ThreadLocal)을 사용해서 커넥션을 보관하고, 트랜잭션 매니저는 내부에서 이 동기화 매니저를 사용한다. 쓰레드 로컬을 쓰기 때문에 여러 요청이 동시에 들어와도 커넥션이 안전하게 분리되고, 커넥션이 필요하면 동기화 매니저에서 꺼내 쓰면 되므로 더 이상 파라미터로 넘기지 않아도 된다.
[ 트랜잭션 매니저 ] ──커넥션 보관──→ [ 동기화 매니저 (ThreadLocal) ]
↑ │ 커넥션 사용
getTransaction() ▼
[ 서비스 ] ──호출──→ [ 리포지토리: DataSourceUtils.getConnection() ]
동작 방식은 다음과 같다.
- 트랜잭션 매니저가 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
- 시작된 커넥션을 동기화 매니저에 보관한다.
- 리포지토리는 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 파라미터로 넘기지 않는다.
- 트랜잭션이 끝나면 매니저가 보관된 커넥션으로 커밋·롤백하고 커넥션을 닫는다.
쓰레드 로컬은 각 쓰레드마다 별도의 저장소를 부여하기 때문에, 해당 쓰레드만 자신의 커넥션에 접근할 수 있다. 이제 이 두 가지, 추상화와 동기화를 실제 코드에 적용해보자.
트랜잭션 매니저 적용
먼저 리포지토리에서는 커넥션을 파라미터로 받던 부분이 모두 사라진다. 대신 커넥션을 가져오고 반환하는 두 곳에서 DataSourceUtils 를 사용해야 한다.
private Connection getConnection() throws SQLException {
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
return DataSourceUtils.getConnection(dataSource);
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
DataSourceUtils.getConnection() 은 동기화 매니저가 관리하는 커넥션이 있으면 그것을 반환하고, 없으면 새 커넥션을 만들어 반환한다. 덕분에 트랜잭션 안에서는 항상 같은 커넥션을 쓰게 된다.
DataSourceUtils.releaseConnection() 은 특히 주의해야 한다. 커넥션을 con.close() 로 직접 닫아버리면, 아직 트랜잭션이 끝나지 않았는데 커넥션이 끊겨 문제가 생긴다. 이 커넥션은 트랜잭션이 커밋·롤백으로 종료될 때까지 살아있어야 하기 때문이다. releaseConnection() 은 동기화된 커넥션이면 닫지 않고 그대로 두고, 동기화된 커넥션이 아닐 때만 닫는다.
서비스는 트랜잭션 매니저를 주입받아 사용한다.
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
bizLogic(fromId, toId, money); // 비즈니스 로직
transactionManager.commit(status); // 성공 시 커밋
} catch (Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
}
지금은 JDBC 기술을 쓰므로 DataSourceTransactionManager 구현체를 주입받고, JPA로 바뀌면 JpaTransactionManager 를 주입받으면 된다. getTransaction() 으로 트랜잭션을 시작하면 TransactionStatus 를 돌려주는데, 여기에 현재 트랜잭션의 상태 정보가 담겨 있어 이후 커밋·롤백할 때 사용한다. 로직이 성공하면 commit(status), 예외가 발생하면 rollback(status) 을 호출한다.
이제 추상화 덕분에 서비스 코드는 JDBC 기술에 직접 의존하지 않고, 기술을 바꿔도 주입하는 구현체만 교체하면 된다. 또 동기화 매니저 덕분에 커넥션을 파라미터로 넘길 필요도 없어졌다. 다만 메서드에 SQLException 이 아직 남아있는데, 이 예외 문제는 뒤에서 따로 해결한다.
트랜잭션 템플릿
한 가지 아쉬운 점이 남는다. 트랜잭션을 사용하는 코드를 보면 늘 같은 패턴이 반복된다. 트랜잭션을 시작하고, 성공하면 커밋, 예외가 나면 롤백한다. 서비스마다 이 try·catch 구조가 똑같이 되풀이되고, 실제로 달라지는 부분은 비즈니스 로직뿐이다. 이런 반복은 템플릿 콜백 패턴으로 해결할 수 있는데, 스프링은 이를 위해 TransactionTemplate 을 제공한다.
public void accountTransfer(String fromId, String toId, int money) {
txTemplate.executeWithoutResult((status) -> {
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
executeWithoutResult() 는 응답 값이 없을 때, execute() 는 응답 값이 있을 때 사용한다. 템플릿의 기본 동작은 비즈니스 로직이 정상 수행되면 커밋하고, 언체크 예외가 발생하면 롤백하는 것이다. 트랜잭션을 시작하고 커밋·롤백하던 코드가 템플릿 안으로 들어가면서 서비스에서는 사라졌다.
코드에 try~catch 가 남은 데는 이유가 있다. bizLogic() 이 SQLException 이라는 체크 예외를 던지는데, 이 람다 안에서는 체크 예외를 밖으로 던질 수 없다. 그래서 언체크 예외로 바꿔 던지도록 전환한 것이다.
템플릿 덕분에 반복 코드는 걷어냈지만, 아직 완전하지 않다. 서비스 로직 안에 여전히 트랜잭션을 처리하는 기술 코드(txTemplate 호출)가 섞여 있기 때문이다. 서비스 입장에서 비즈니스 로직은 핵심 기능이고 트랜잭션은 부가 기능인데, 이 둘이 한 클래스에 섞여 있으면 유지보수가 어려워진다. 서비스에는 핵심 비즈니스 로직만 남기는 것이 이상적이다.
트랜잭션 AOP
서비스에서 트랜잭션 코드를 완전히 걷어내려면 스프링 AOP로 프록시를 도입하면 된다. 프록시는 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 객체를 분리해준다.
[프록시 도입 전]
클라이언트 ─→ [ 서비스 (비즈니스 로직 + 트랜잭션 처리) ] ─→ [ 리포지토리 ]
[프록시 도입 후]
클라이언트
│
▼
[ 트랜잭션 프록시 ] ← 트랜잭션 시작/종료를 담당
│ 실제 서비스 호출
▼
[ 서비스: 순수 비즈니스 로직만 ]
│
▼
[ 리포지토리 ]
프록시가 트랜잭션 처리 로직을 모두 가져간 뒤, 트랜잭션을 시작하고 실제 서비스를 대신 호출한다. 그 덕분에 서비스 계층에는 순수한 비즈니스 로직만 남는다.
이 프록시를 직접 만들 수도 있지만, 트랜잭션은 워낙 많이 쓰는 기능이라 스프링이 필요한 기능을 모두 제공한다. 개발자는 트랜잭션이 필요한 곳에 @Transactional 애노테이션만 붙이면 된다.
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
트랜잭션 코드가 전부 사라지고 순수한 로직만 남았다. 스프링의 트랜잭션 AOP가 이 애노테이션을 인식해서 프록시를 자동으로 적용해준다. @Transactional 은 메서드에 붙여도 되고 클래스에 붙여도 되는데, 클래스에 붙이면 외부에서 호출할 수 있는 public 메서드가 적용 대상이 된다.
실제로 프록시가 적용됐는지는 테스트로 확인할 수 있다. AopUtils.isAopProxy() 로 확인해보면 서비스에는 CGLIB 로 만든 프록시가 적용되고, 애노테이션을 붙이지 않은 리포지토리에는 적용되지 않는다. 한 가지 주의할 점은, 스프링 AOP는 스프링 컨테이너 위에서 동작하므로 테스트에도 @SpringBootTest 를 붙여 컨테이너를 띄워야 한다는 것이다.
선언적 트랜잭션 관리와 프로그래밍 방식
이렇게 @Transactional 애노테이션 하나만 선언해서 트랜잭션을 적용하는 방식을 선언적 트랜잭션 관리라고 한다. 해당 로직에 트랜잭션을 적용하겠다고 선언만 하면 되는 방식이다. 반대로 트랜잭션 매니저나 트랜잭션 템플릿으로 트랜잭션 코드를 직접 작성하는 방식은 프로그래밍 방식의 트랜잭션 관리라고 한다.
선언적 방식이 훨씬 간편하고 실용적이라, 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다. 프로그래밍 방식은 스프링 컨테이너나 AOP 없이도 쓸 수 있지만 실무에서는 거의 쓰이지 않고, 테스트에서 가끔 사용된다.
스프링 부트의 자동 리소스 등록
마지막으로, 지금까지 당연하게 사용한 데이터소스와 트랜잭션 매니저가 어떻게 등록되는지 짚어본다. 과거에는 이 둘을 개발자가 직접 스프링 빈으로 등록해야 했다.
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
스프링 부트는 이 둘을 자동으로 등록해준다. 데이터소스는 dataSource 라는 이름으로 등록되고, application.properties 에 적어둔 속성을 사용해 생성된다. 이때 기본으로 만들어지는 데이터소스는 커넥션 풀을 제공하는 HikariDataSource 다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
트랜잭션 매니저도 transactionManager 라는 이름으로 자동 등록된다. 어떤 매니저를 등록할지는 라이브러리를 보고 판단하는데, JDBC를 쓰면 DataSourceTransactionManager, JPA를 쓰면 JpaTransactionManager 를 등록한다. 물론 개발자가 직접 빈으로 등록하면 자동 등록은 동작하지 않는다.
정리
이번 글에서는 서비스 계층을 순수하게 지키기 위한 과정을 단계별로 따라왔다. 트랜잭션 추상화로 기술 종속을 끊고, 동기화 매니저로 커넥션 전달을 없애고, 템플릿으로 반복을 줄이고, 마지막에 AOP로 트랜잭션 코드를 서비스 밖으로 완전히 밀어냈다. 그 결과 개발자는 트랜잭션이 필요한 곳에 @Transactional 한 줄만 붙이면 되고, 프록시 생성과 트랜잭션 처리는 스프링이 알아서 해준다.
이렇게 앞서 정리한 세 가지 문제 중 트랜잭션 문제를 해결했다. 남은 예외 누수 문제와 JDBC 반복 문제는 다음 포스트에서 다룬다.
'BackEnd > Spring' 카테고리의 다른 글
| [SpringDB1] 스프링과 문제 해결 - 예외 처리, 반복 (1) | 2026.07.30 |
|---|---|
| [SpringDB1] 자바 예외 이해 (0) | 2026.07.19 |
| [SpringDB1] 트랜잭션 이해 (0) | 2026.06.26 |
| [SpringDB1] 커넥션 풀과 데이터소스 (0) | 2026.06.21 |
| [SpringDB1] JDBC 이해 (0) | 2026.06.20 |