[Spring] 트랜잭션 서비스 추상화
토비의 스프링에서 나오는 트랜잭션 서비스의 추상화는 지금까지도 활용되고 있으며 스프링부트 환경에서도 가장 중요한 핵심 아키텍처로 활용되고 있습니다. 과거의 복잡한 트랜잭션 코드가 어떻게 @Transactional이라는 하나의 어노테이션으로 관리할 수 있게 되었는지 살펴보도록 하겠습니다.
1. 트랜잭션의 시작: 원자성(Atomicity)
DB는 단일 SQL에 대해서는 트랜잭션을 보장하지만, 여러 작업을 하나의 단위로 묶는 것은 애플리케이션의 역활입니다. 이 역활은 AI의 개발을 위임하더라도 트랜잭션의 범위 만큼은 백엔드 개발자가 직접 검토해야할 정도로 중요한 작업니다.
예: 은행 송금 로직
- 출금 계좌에서 돈을 뺀다. (UPDATE)
- -- 여기서 예외 발생! --
- 입금 계좌에 돈을 더한다. (UPDATE)
2번에서 에러가 나면 1번 작업은 취소(Rollback)되어야 합니다. 그렇지 않으면 돈은 사라지고 아무도 받지 못하는 대참사가 일어납니다.
2. JDBC 트랜잭션의 한계: Connection 파라미터 지옥
초기 JDBC 방식에서는 트랜잭션을 관리하기 위해 Connection 객체를 직접 다뤄야 했습니다.
public void upgradeLevels() throws Exception {
Connection c = dataSource.getConnection();
c.setAutoCommit(false); // 트랜잭션 시작
try {
// 비즈니스 로직 수행 (UserDao에 c를 계속 넘겨야 함)
userDao.update(c, user1);
userDao.update(c, user2);
c.commit(); // 성공 시 커밋
} catch (Exception e) {
c.rollback(); // 실패 시 롤백
throw e;
} finally {
c.close();
}
}
이 코드의 문제점
왜 비즈니스 로직에 기술적인 로직을 넣는 것을 조심해야할까요?비즈니스 로직은 요구사항에 따라 자주 변하는 반면, 기술적 인프라는 상대적으로 안정적입니다. 이 둘이 섞여 있으면 비즈니스 변경 시 기술 코드까지 함께 수정해야 하는 문제가 생깁니다. 이전에 정리한 글 처럼 자주 변하는 영역과 변하지 않는 영역을 분리하는 건 안정적인 시스템을 운영함에 있어서 필수적입니다.
- 비즈니스 로직 오염: 순수해야 할 서비스 레이어에 JDBC API(Connection)가 침투합니다.
- 파라미터 전파: 모든 DAO 메서드 시그니처에 Connection이 추가되어 코드가 지저분해집니다.
- 기술 종속: 만약 JPA나 Hibernate로 바꾸고 싶다면? 모든 코드를 다시 짜야 합니다.
3. 스프링의 핵심 기술: 트랜잭션 동기화와 추상화
스프링은 이 문제를 두 가지 핵심 기술로 해결했습니다.
(1) 트랜잭션 동기화(Transaction Synchronization)
ThreadLocal을 사용하여 트랜잭션 시작 시 생성된 Connection을 트랜잭션 동기화 저장소에 보관합니다. 이후 호출되는 DAO들은 파라미터로 전달받지 않아도 트랜잭션 동기화 저장소에서 알아서 꺼내 씁니다.
(2) PlatformTransactionManager (서비스 추상화)
기술(JDBC, JPA, JTA 등)에 상관없이 트랜잭션을 제어할 수 있는 공통 인터페이스를 제공합니다.
// 현대적인 설정 방식 (Java Config)
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
이제 서비스 코드는 커넥션을 생성하는 기술에 독립적으로 변화하였습니다. 하지만 트랜잭션을 정의하고 롤백하는 반복적인 코드(boilerplate)가 여전히 남아있고, 비즈니스 로직이 트랜잭션 관리 코드와 섞여 있다는 근본적인 문제는 해결되지 않았습니다.
public void upgradeLevels() {
// 트랜잭션 정의와 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 순수 비즈니스 로직
userDao.getAll().forEach(this::upgradeLevel);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
4. 현대적 해답: 선언적 트랜잭션 @Transactional
오늘날 우리는 위와 같은 코드조차 짜지 않습니다. 스프링 AOP를 활용한 선언적 트랜잭션 덕분입니다. 실제 서비스 로직을 바로 호출하는 것이 아니라 프록시 객체를 통해 비즈니스 로직을 실행하면서 더 높은 수준으로 추상화할 수 있었습니다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserDao userDao;
@Transactional // 이 어노테이션 하나로 모든 트랜잭션 경계 설정 끝!
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
}
이 코드는 단일 책임 원칙(SRP)을 완벽히 지킵니다. UserService는 오직 "사용자 레벨을 어떻게 올릴 것인가"에만 집중하면 됩니다.
5. 서비스 추상화의 또 다른 사례: JavaMail
트랜잭션뿐만 아니라 외부 시스템(이메일 발송 등)과의 연동도 추상화를 하는 곳입니다. JavaMail은 인터페이스가 아닌 구체 클래스 중심이라 테스트하기 매우 까다롭습니다. 스프링은 MailSender라는 인터페이스를 통해 이를 추상화했습니다.
테스트를 위한 목(Mock) 오브젝트 활용
실제 메일을 보내지 않고도 "메일 발송 요청이 제대로 갔는가"를 확인하려면 테스트 대역이 필요합니다.
// 테스트용 가짜 메일 발송기
public class MockMailSender implements MailSender {
private List<String> requests = new ArrayList<>();
@Override
public void send(SimpleMailMessage message) {
requests.add(message.getTo()[0]); // 요청된 수신자 저장
}
}
현대적인 테스트 코드(JUnit 5 + Mockito)에서는 다음과 같이 더 우아한 방식으로 문제를 해결할 수 있습니다.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private MailSender mailSender; // Mockito가 가짜 객체 생성
@InjectMocks private UserService userService;
@Test
void upgradeLevels_ShouldSendEmail() {
// Given & When
userService.upgradeLevels();
// Then: 메일 발송 메서드가 최소 2번 호출되었는지 검증
verify(mailSender, times(2)).send(any(SimpleMailMessage.class));
}
}
6. 다중 트랜잭션의 추상화
트랜잭션의 추상화는 단순히 하나의 메서드뿐만 아니라 다른 비즈니스 로직의 의존성을 분리하여 트랜잭션 경계를 정의하는 방법과 부모 트랜잭션과 자식 트랜잭션의 전파 방식까지 어노테이션으로 정의할 수 있을정도로 추상화할 수 있게 되었습니다.
1. 트랜잭션 전파 레벨 (Propagation)
- REQUIRED, REQUIRES_NEW, NESTED 등 7가지 전파 옵션
- 실무에서 자주 쓰는 REQUIRES_NEW(독립 트랜잭션)와 REQUIRED(기본값) 비교
- 전파 레벨 오용 시 발생하는 문제 (셀프 호출 시 프록시 미적용 등)
@Service
public class OrderService {
@Transactional
public void createOrder() {
saveLog(); // ❌ 프록시를 거치지 않아 REQUIRES_NEW 무시됨
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog() { ... }
}
2. @TransactionalEventListener
- ApplicationEventPublisher로 도메인 이벤트 발행
- @TransactionalEventListener(phase = AFTER_COMMIT) — 트랜잭션 커밋 후 이벤트 처리
- 트랜잭션과 부가 로직(메일 발송, 알림 등)의 관심사 분리
7. 왜 이런 수고를 해야 하는가?
단순히 기능을 만드는 데는 JDBC 코드가 빠를 수 있습니다. 하지만 시스템이 커지고 기술 환경이 변할 때(예: 단일 DB에서 분산 DB로, JDBC에서 JPA로), 추상화와 DI를 적용한 코드는 빛을 발합니다.
- 변화에 유연함: 기술 설정만 바꾸면 비즈니스 로직은 그대로 유지됩니다.
- 테스트 용이성: 환경에 구애받지 않는 단위 테스트가 가능해집니다.
- 응집도 향상: 각 클래스가 자기 할 일에만 집중합니다.
스프링은 결국 객체지향의 원칙을 잘 지킬 수 있도록 돕는 도구일 뿐입니다. 우리가 집중해야 할 것은 어노테이션의 종류가 아니라, 그 뒤에 숨은 관심사의 분리라는 본질입니다.
'spring' 카테고리의 다른 글
| 토비의 스프링 정복하기 9편 - 서비스의 추상화 (0) | 2026.02.03 |
|---|---|
| 토비의 스프링 8편 - 스프링 예외 처리 실전편 (0) | 2026.01.31 |
| 토비의 스프링 7편 - 스프링 예외 처리의 철학 (1) | 2026.01.28 |
| 토비의 스프링 정복하기 6편 - 제어의 역전 (0) | 2026.01.25 |
| 토비의 스프링 정복하기 5편 - 변하는 것과 변하지 않는 것 (0) | 2026.01.21 |