🧩 순수한 서비스 계층의 중요성과 문제점
애플리케이션을 설계할 때 일반적으로는 역할에 따라 세 가지 계층으로 나눈다.
- 프레젠테이션 계층 (Controller)
- 역할: UI 처리, 웹 요청 검증 및 응답
- 사용 기술: 서블릿, 스프링 MVC
- 서비스 계층 (Service)
- 역할: 비즈니스 로직 처리
- 사용 기술: 특정 기술에 의존하지 않은 순수 자바 코드
- 데이터 접근 계층 (Repository)
- 역할: 데이터베이스 접근
- 사용 기술: JDBC, JPA, File, Redis
⚠️ 이 구조에서 가장 중요한 것은 서비스 계층의 순수성을 유지하는 것이다.
즉, 서비스 계층은 특정 기술(예: JDBC, JPA)에 종속되지 않고 순수 자바 코드로 작성되어,
데이터 저장 기술이 바뀌거나 UI가 변경되어도 비즈니스 로직은 영향을 받지 않아야 한다.
🚨 하지만 이전의 JDBC 트랜잭션 처리 방식(MemberServiceV2)은 서비스 계층의 순수성을 해치는 다음과 같은 문제들을 가지고 있다.
- 기술의 누수:
-
트랜잭션을 처리하기 위해 java.sql.Connection, SQLException 등 JDBC 전용 기술이 서비스 계층 코드에 직접 노출되었다.
-
만약 데이터 기술을 JDBC에서 JPA로 변경한다면, 서비스 계층의 코드도 전부 수정해야 했다.
-
-
커넥션 동기화의 어려움:
-
하나의 커넥션을 유지하기 위해 서비스 계층에서 생성한 Connection 객체를 리포지토리 메서드에 파라미터로 계속 넘겼다.
-
이는 코드를 지저분하게 만들고, 트랜잭션용 메서드와 아닌 메서드를 따로 만들어야 하는 불편함을 초래했다.
-
- 반복적인 코드:
- 트랜잭션을 시작하고, 커밋 또는 롤백하는 try-catch-finally 구조가 모든 비즈니스 로직마다 반복적으로 등장했다.
✅ 스프링의 해결책1: 트랜잭션 추상화 (PlatformTransactionManager)
스프링은 트랜잭션 기술 자체를 추상화한, PlatformTransactionManager라는 인터페이스를 제공한다.
public interface PlatformTransactionManager extends TransactionManager {
// 트랜잭션 시작 (전파 동작에 따라 현재 활성화된 트랜잭션 또는 새로운 트랜잭션 반환)
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
// 트랜잭션 커밋
void commit(TransactionStatus status) throws TransactionException;
// 트랜잭션 롤백
void rollback(TransactionStatus status) throws TransactionException;
}
이를 통해 트랜잭션 기술(JDBC, JPA 등)과 서비스 계층 코드를 분리할 수 있다.
서비스 계층 (비즈니스 로직) → PlatformTransactionManager (트랜잭션 기술)
/ \
JdbcTransactionManager JpaTransactionManager
(JDBC) (JPA)
- 이제 서비스 계층은 PlatformTransactionManager라는 추상화된 인터페이스에만 의존한다.
- 데이터 접근 기술(예: JDBC, JPA)이 바뀌어도, 서비스 코드는 전혀 변경할 필요가 없다.
✅ 스프링의 해결책2: 트랜잭션 동기화 (TransactionSynchronizationManager)
커넥션을 파라미터로 계속 넘겨야 했던 문제는 트랜잭션 동기화 매니저를 통해 해결한다.
비즈니스 로직 → 트랜잭션 매니저 { 트랜잭션 동기화 매니저 } 리포지토리
↓ ↓
(트랜잭션 시작) → ThreadLocal에 커넥션 보관 → (커넥션 사용)
↓
(트랜잭션 종료) → 보관된 커넥션 해제, 닫기
- 트랜잭션 매니저가 트랜잭션을 시작하면, 사용될 커넥션을 트랜잭션 동기화 매니저 내부의 ThreadLocal에 보관한다.
- 이후 리포지토리에서는 DataSourceUtils.getConnection()을 통해 ThreadLocal에 보관된 커넥션을 꺼내 사용한다.
- 트랜잭션이 종료되면, 트랜잭션 동기화 매니저가 보관했던 커넥션을 해제하고 닫는다.
ThreadLocal은 각각의 쓰레드마다 별도의 저장 공간을 제공하므로 멀티 쓰레드 환경에서도 안전하다.
이 메커니즘 덕분에 더 이상 커넥션을 파라미터로 지저분하게 전달할 필요가 없어졌다.
📜 PlatformTransactionManager와 TransactionSynchronizationManager를 적용한 예시 코드
@RequiredArgsConstructor
public class MemberRepositoryV3 {
private final DataSource dataSource;
// 등록
public Member save(Member member) throws SQLException { ... }
// 조회
public Member findById(String memberId) throws SQLException { ... }
// 수정
public void update(String memberId, int money) throws SQLException { ... }
// 삭제
public void delete(String memberId) throws SQLException { ... }
// 커넥션 획득
private Connection getConnection() throws SQLException {
// 트랜잭션 동기화 매니저가 ThreadLocal에 보관한 커넥션을 가져옴 (없으면 새로 생성)
return DataSourceUtils.getConnection(dataSource);
}
// 역순으로 리소스 정리
private void close(Connection connection, Statement statement, ResultSet resultSet) {
JdbcUtils.closeResultSet(resultSet);
JdbcUtils.closeStatement(statement);
// JdbcUtils.closeConnection(connection); // 커넥션을 유지해야 하므로, 닫으면 안 됨
DataSourceUtils.releaseConnection(connection, dataSource);
}
}
⚠️ 트랜잭션 동기화 매니저를 사용하려면 스프링의 DataSourceUtils를 사용해야 한다.
- DataSourceUtils.getConnection():
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 → 해당 커넥션을 반환한다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면 → 새로운 커넥션을 생성해서 반환한다.
- DataSourceUtils.releaseConnection():
- 트랜잭션을 사용하기 위해 동기화된 커넥션이면 → 닫지 않고 그대로 유지해 준다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 아니면 → 해당 커넥션을 바로 닫는다.
- ⚠️ 커넥션을 직접 닫을 경우(JdbcUtils.closeConnection()), 커넥션이 유지되지 않는 문제가 발생한다.
- 따라서 커넥션은 트랜잭션 종료(커밋, 롤백) 시점까지 유지되어야 한다.
@RequiredArgsConstructor
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 1. 트랜잭션 시작 (트랜잭션 매니저는 내부적으로 DataSource를 통해 커넥션을 생성함)
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 2. 비즈니스 로직 수행
doLogic(fromId, toId, money);
// 3.1. 성공 시 커밋
transactionManager.commit(status);
} catch (Exception e) {
// 3.2. 실패 시 롤백
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
// 트랜잭션 매니저가 알아서 커넥션을 종료해줌
}
private void doLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney() + money);
}
}
- transactionManager.getTransaction(new DefaultTransactionDefinition()):
- 트랜잭션을 시작하고, TransactionStatus를 반환한다.
- TransactionStatus에는 현재 트랜잭션의 상태 정보가 포함되어 있다. (이후 트랜잭션을 커밋, 롤백할 때 필요하다.)
- TransactionDefinition 파라미터를 통해 트랜잭션과 관련된 옵션을 지정할 수 있다.
- transactionManager.commit(status): 트랜잭션을 커밋한다.
- transactionManager.rollback(status): 트랜잭션을 롤백한다.
🗺️ 전체 과정 요약
// 1. 트랜잭션 시작
서비스 계층 코드 → { 트랜잭션 매니저 }
↓
트랜잭션 시작 (tm.getTransaction())
(DataSource를 통해 커넥션 획득)
(커넥션을 수동 커밋 모드로 전환)
↓
{ 트랜잭션 동기화 매니저 }
↓
ThreadLocal에 커넥션 보관
// 2. 비즈니스 로직 실행
서비스 계층 코드 → 비즈니스 로직 → { 리포지토리 getConnection() }
↓ ↑
동기화된 커넥션 조회 ThreadLocal에
DataSourceUtils.getConnection() 보관된 커넥션 반환
↓ ↑
{ 트랜잭션 동기화 매니저 }
// 3. 트랜잭션 종료 (커밋 / 롤백)
서비스 계층 코드 → { 트랜잭션 매니저 }
↓
커밋 (tm.commit()) 또는 롤백 (tm.rollback())
(동기화된 커넥션 획득, 커밋 또는 롤백 수행)
(ThreadLocal 정리, 자동 커밋 활성화, 커넥션 종료)
↓
{ 트랜잭션 동기화 매니저 }
✅ 스프링의 해결책3: 트랜잭션 템플릿 (TransactionTemplate)
서비스 계층에서 트랜잭션을 위한 반복적인 try-catch 코드를 해결하기 위해, 스프링은 TransactionTemplate 클래스를 제공한다.
public class TransactionTemplate extends DefaultTransactionDefinition
implements TransactionOperations, InitializingBean {
private PlatformTransactionManager transactionManager;
// 응답 값이 있을 때 사용
public <T> T execute(TransactionCallback<T> action) throws TransactionException { ... }
// 응답 값이 없을 때 사용
void executeWithoutResult(Consumer<TransactionStatus> action)
throws TransactionException {
...
}
이는 템플릿 콜백 패턴을 활용한 것으로, 개발자는 반복적인 트랜잭션 처리(시작, 커밋, 롤백)는 템플릿에 맡기고,
순수한 비즈니스 로직만 콜백 함수로 제공하면 된다.
public class MemberServiceV3_2 {
private final TransactionTemplate txTemplate;
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager); // transactionManager 주입
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) {
// 트랜잭션 템플릿 안에서 트랜잭션을 시작함
txTemplate.executeWithoutResult(transactionStatus -> {
// 비즈니스 로직 수행 (람다는 체크 예외(SQLException) 발생 시 밖으로 던질 수 없으므로 try-catch 필요)
try {
doLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e); // 언체크 예외(RuntimeException)로 변환하여 던지기
}
}); // 언체크(런타임) 예외 발생 시 롤백, 그 외에는 커밋 (체크 예외 포함)
}
// throws SQLException을 제외하면 순수한 자바 코드만 남은 비즈니스 로직
private void doLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney() + money);
}
}
- 트랜잭션 템플릿은 생성자의 파라미터로 TransactionManager가 필요하다.
- 트랜잭션 템플릿의 기본 동작은 다음과 같다.
- 언체크 예외(RuntimeException 등) 발생 → 롤백 ❌
- 정상 수행되거나 체크 예외(SQLException 등) 발생 → 커밋 ✅
✅ 스프링의 해결책4: 선언적 트랜잭션 관리 (AOP + @Transactional)
가장 궁극적인 해결책은 스프링 AOP의 프록시 패턴을 활용하는 것이다.
@Transactional 애노테이션을 메서드나 클래스에 붙이면, 스프링은 해당 객체에 대한 프록시(Proxy)를 생성한다.
이 프록시는 비즈니스 로직을 담은 원본 객체를 대신하여 호출을 받는다.
클라이언트 → \ - 서비스 메서드
(가로챔) ↓ ↑
{ 프록시 } → 원본 객체의 메서드 호출
↓
트랜잭션 시작
↓
{ 트랜잭션 매니저 }
- 클라이언트가 서비스 메서드를 호출하면, 먼저 트랜잭션 프록시가 호출을 가로챈다.
- 프록시는 스프링 컨테이너를 통해 트랜잭션 매니저를 획득하여 트랜잭션을 시작한다.
- 트랜잭션이 시작되면 프록시는 실제 비즈니스 로직을 담은 원본 객체의 메서드를 호출한다.
- 비즈니스 로직이 성공적으로 끝나면 프록시가 트랜잭션을 커밋한다.
- 예외가 발생하면 프록시가 트랜잭션을 롤백한다.
이 방식을 "선언적 트랜잭션 관리"라고 부르며, 기존의 트랜잭션 매니저나 트랜젝션 템플릿을 사용해 트랜잭션 관련 코드를 직접 작성했던 "프로그래밍 방식의 트랜잭션 관리"에 비해 훨씬 간편하고 실용적이기 때문에 실무에서는 대부분 이를 사용한다.
@RequiredArgsConstructor
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
@Transactional // 클래스에 선언 시 모든 public 메서드에 다 적용됨
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
doLogic(fromId, toId, money);
}
private void doLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney() + money);
}
}
- 이제 트랜잭션 관련 코드는 @Transactional 애노테이션밖에 없다.
- @Transactional을 클래스 레벨에 선언할 경우, 외부에서 호출 가능한 모든 public 메서드에 적용된다.
테스트 코드 예시:
@SpringBootTest // @Transactional 사용하려면 스프링 컨테이너가 필요함
class MemberServiceV3_3Test {
@Autowired
private MemberServiceV3_3 memberService;
@Autowired
private MemberRepositoryV3 memberRepository;
@AfterEach
void tearDown() throws SQLException {
memberRepository.delete("memberA");
memberRepository.delete("memberB");
memberRepository.delete("ex");
}
@Test
void 정상_이체() throws SQLException {
Member memberA = new Member("memberA", 10000);
Member memberB = new Member("memberB", 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 5000);
Member foundMemberA = memberRepository.findById(memberA.getMemberId());
Member foundMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(foundMemberA.getMoney()).isEqualTo(5000);
assertThat(foundMemberB.getMoney()).isEqualTo(15000);
}
@Test
void 이체_중_예외_발생() throws SQLException {
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
assertThatThrownBy(() ->
memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 5000)
).isInstanceOf(IllegalStateException.class);
Member foundMemberA = memberRepository.findById(memberA.getMemberId());
Member foundMemberB = memberRepository.findById(memberEx.getMemberId());
assertThat(foundMemberA.getMoney()).isEqualTo(10000); // 롤백되어야 함
assertThat(foundMemberB.getMoney()).isEqualTo(10000);
}
@TestConfiguration
static class TestConfig {
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
}
- @TestConfiguration: 테스트 클래스 안에 선언 시 스프링 부트가 자동 생성하는 빈 외에 추가적으로 필요한 빈을 등록할 수 있다.
- transactionManager(): 스프링의 트랜잭션 AOP는 스프링 빈으로 등록된 트랜잭션 매니저를 찾아서 사용한다.
👉 이제는 @Transactional이라는 선언 하나만으로 트랜잭션 관련 코드를 비즈니스 로직에서 완벽하게 분리할 수 있다.
이로써 서비스 계층에는 순수한 비즈니스 로직만 남게 되어 유지보수성과 테스트 용이성이 극대화된다.
🧩 스프링 부트의 자동 리소스 등록
과거에는 DataSource와 PlatformTransactionManager를 개발자가 직접 XML 파일이나 자바 설정 파일(@Configuration) 등을 통해 스프링 빈으로 등록해야 했다. 하지만 스프링 부트는 이 과정을 모두 자동화했다.
👉 개발자는 단지 application.properties 파일에 데이터베이스 연결 정보만 입력하면 된다!
# application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
스프링 부트는 위 속성을 이용하여 다음과 같이 동작한다.
- 기본적으로 HikariCP 기반의 커넥션 풀을 제공하는 DataSource인, HikariDataSource를 생성하여 스프링 빈으로 등록한다.
- 또한 현재 라이브러리에 맞는 PlatformTransactionManager를 스프링 빈으로 등록해 준다.
즉, 위 속성만 입력하면 스프링 부트가 자동으로 DataSource, 트랜잭션 매니저를 스프링 빈으로 등록해 준다.
따라서 개발자는 의존 관계 주입을 통해 편리하게 해당 빈을 사용하면 된다.
'Spring DB' 카테고리의 다른 글
| [Spring DB] JdbcTemplate (0) | 2025.10.02 |
|---|---|
| [Spring DB] 스프링의 예외 추상화 (DataAccessException), 예외 변환기 (SQLExceptionTranslator) (0) | 2025.09.23 |
| [Spring DB] 애플리케이션에 트랜잭션 적용 – 세션, 락, 자동 커밋, 커넥션 동기화 (0) | 2025.09.23 |
| [Spring DB] 커넥션 풀, DataSource (1) | 2025.09.22 |
| [Spring DB] JDBC (Java Database Connectivity) (0) | 2025.09.22 |