데이터베이스를 다루는 서비스 코드에서 트랜잭션을 빠뜨리면, 겉보기에는 잘 돌아가다가 운영 환경에서 데이터 불일치라는 최악의 버그를 만나게 된다. "테스트할 땐 괜찮았는데..." 라는 말이 나오는 대표적인 원인이다.
트랜잭션이란
트랜잭션은 여러 작업을 하나의 논리적 단위로 묶는 것이다. "전부 성공하거나, 전부 실패하거나" — 이것이 트랜잭션의 핵심이다.
은행 송금을 떠올려보자. A 계좌에서 출금하고 B 계좌에 입금하는 두 작업은 반드시 함께 성공하거나 함께 실패해야 한다. 출금만 되고 입금이 실패하면 돈이 사라진다.
ACID 속성
- Atomicity(원자성) — 트랜잭션의 모든 작업이 하나의 단위. 하나라도 실패하면 전체 롤백.
- Consistency(일관성) — 트랜잭션 전후로 데이터의 무결성 제약이 유지됨.
- Isolation(격리성) — 동시에 실행되는 트랜잭션들이 서로 간섭하지 않음.
- Durability(지속성) — 커밋된 데이터는 시스템 장애가 발생해도 유지됨.
스프링의 @Transactional
스프링은 @Transactional 어노테이션으로 선언적 트랜잭션 관리를 제공한다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentRepository paymentRepository;
@Transactional
public Order createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
paymentRepository.save(new Payment(order)); // 여기서 실패하면?
return order; // order도 롤백된다
}
}
@Transactional이 붙은 메서드가 호출되면 트랜잭션이 시작된다.- 메서드가 정상 종료되면 커밋, 예외가 발생하면 롤백된다.
- 메서드 내의 모든 DB 작업이 하나의 트랜잭션으로 묶인다.
트랜잭션 전파(Propagation)
메서드 A가 메서드 B를 호출할 때, B의 트랜잭션은 어떻게 될까? 이것을 결정하는 것이 전파 속성이다.
주요 전파 옵션
- REQUIRED (기본값) — 기존 트랜잭션이 있으면 참여, 없으면 새로 생성. 대부분 이것으로 충분하다.
- REQUIRES_NEW — 항상 새 트랜잭션 생성. 기존 트랜잭션은 일시 중단. 로깅처럼 호출자의 실패와 무관하게 기록을 남겨야 할 때 사용한다.
- SUPPORTS — 기존 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행.
- MANDATORY — 기존 트랜잭션이 반드시 있어야 함. 없으면 예외 발생.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logActivity(Activity activity) {
// 메인 트랜잭션이 롤백되더라도 이 로그는 남는다
activityLogRepository.save(activity);
}
읽기 전용 트랜잭션
조회만 하는 메서드에는 readOnly = true를 설정하는 것이 좋다.
@Service
@Transactional(readOnly = true) // 클래스 레벨 기본값
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public List<UserDto> getAllUsers() {
return userRepository.findAll().stream()
.map(UserMapper::toDto)
.toList();
}
@Transactional // 쓰기 작업은 메서드 레벨에서 오버라이드
public UserDto createUser(UserCreateRequest request) {
User user = userRepository.save(new User(request));
return UserMapper.toDto(user);
}
}
- 클래스 레벨에
readOnly = true를 선언하고, 쓰기 작업 메서드에만@Transactional을 붙여 오버라이드하는 패턴이 실무에서 가장 흔하다. readOnly = true의 이점- Hibernate의 dirty checking을 비활성화하여 성능 최적화
- DB에 따라 읽기 전용 연결을 사용하여 리소스 절약
- 실수로 데이터를 변경하는 코드가 있으면 예외 발생
롤백 규칙
스프링의 기본 롤백 규칙은 언체크 예외(RuntimeException 포함)에서만 롤백하고, 체크 예외(Exception)에서는 커밋한다.
@Transactional
public void processPayment() {
// RuntimeException → 롤백 O (기본)
// IOException → 롤백 X (기본)
}
@Transactional(rollbackFor = Exception.class)
public void processPayment() {
// 모든 예외에서 롤백
}
체크 예외를 던지는 코드가 트랜잭션 안에 있다면, rollbackFor를 명시적으로 지정하지 않으면 예외가 발생해도 커밋된다. 이것은 많은 개발자가 놓치는 함정이다.
심화 분석
@Transactional의 동작 원리
@Transactional은 스프링 AOP 프록시를 통해 동작한다.
스프링이 @Transactional이 붙은 빈을 감지하면, 원본 객체 대신 프록시 객체를 생성한다. 외부에서 메서드를 호출하면 프록시가 먼저 트랜잭션을 시작하고, 원본 메서드를 호출한 뒤, 결과에 따라 커밋 또는 롤백한다.
JPA의 변경 감지와 트랜잭션
JPA를 사용할 때 @Transactional이 특히 중요한 이유가 있다. JPA의 변경 감지(Dirty Checking) 메커니즘은 트랜잭션이 있어야만 동작한다.
@Transactional
public void updateUsername(UUID id, String newName) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
user.setUsername(newName);
// save()를 호출하지 않아도 트랜잭션 커밋 시 자동으로 UPDATE 쿼리 발생
}
트랜잭션이 없으면 변경 감지가 동작하지 않고, 엔티티를 수정한 뒤 반드시 save()를 명시적으로 호출해야 한다. 트랜잭션을 선언하면 JPA가 자동으로 변경사항을 감지하여 DB에 반영하므로 코드가 더 간결해진다.
삭제 순서와 FK 제약
관계가 있는 엔티티를 삭제할 때는 외래 키 제약 조건을 반드시 고려해야 한다.
// 위험한 삭제 순서
public void deleteChannel(UUID channelId) {
channelRepository.deleteById(channelId); // 채널 먼저 삭제
messageRepository.deleteByChannelId(channelId); // FK 위반 가능!
readStatusRepository.deleteByChannelId(channelId);
}
// 올바른 삭제 순서
@Transactional
public void deleteChannel(UUID channelId) {
readStatusRepository.deleteByChannelId(channelId); // 자식 먼저
messageRepository.deleteByChannelId(channelId); // 자식 먼저
channelRepository.deleteById(channelId); // 부모 마지막
}
자식 테이블(FK를 가진 쪽)을 먼저 삭제하고, 부모 테이블을 마지막에 삭제해야 한다. 그리고 이 전체 과정을 @Transactional로 묶어야 중간에 실패했을 때 일관성이 보장된다.
자주 하는 실수
@Transactional 누락
서비스 클래스에 @Transactional을 아예 사용하지 않는 경우. 단일 쿼리라면 DB가 자체적으로 트랜잭션을 관리하지만, 여러 쿼리를 실행하는 메서드에서는 부분 실패가 발생할 수 있다.
특히 "사용자 생성 → 프로필 이미지 저장 → 읽기 상태 초기화"처럼 여러 테이블에 걸친 작업에서 트랜잭션 없이는 중간에 실패 시 좀비 데이터가 남는다.
같은 클래스 내부 호출
@Service
public class UserService {
public void outer() {
this.inner(); // 프록시를 거치지 않으므로 @Transactional 무시!
}
@Transactional
public void inner() {
// 트랜잭션이 적용되지 않음
}
}
같은 클래스 내에서 this로 호출하면 프록시를 거치지 않기 때문에 @Transactional이 동작하지 않는다. 해결 방법은 다음과 같다.
- 트랜잭션이 필요한 메서드를 별도의 서비스 클래스로 분리
TransactionTemplate을 사용한 프로그래매틱 트랜잭션 적용
private 메서드에 @Transactional
@Transactional // 무시됨!
private void saveInternal(User user) {
userRepository.save(user);
}
스프링 AOP 프록시는 public 메서드에서만 동작한다. private, protected 메서드의 @Transactional은 조용히 무시된다.
프로필 저장 누락 버그
public UserDto update(UUID id, UserUpdateRequest dto, MultipartFile profile) {
User user = userRepository.findById(id).orElseThrow();
if (profile != null) {
BinaryContent saved = new BinaryContent(...);
binaryContentStorage.put(saved.getId(), profile.getBytes());
// user.setProfile(saved)가 빠져 있음! → DB에 프로필 연결 누락
}
userRepository.save(user);
return UserMapper.toDto(user);
}
BinaryContent를 저장소에 업로드했지만 User 엔티티와 연결하지 않으면, 파일은 존재하지만 사용자와 연결되지 않은 고아 데이터가 된다. 이 역시 트랜잭션으로 묶어야 롤백 시 일관성이 유지된다.