모든 코드는 github 에 있습니다.
들어가기 앞서...
다음과 같은 서비스 메서드가 있을 때,
@RequiredArgsConstructor
@Service
public class HumanHandler {
private final HumanRepository humanRepository;
//passOneyear을 3번 호출한다.
public void liveLife(Long humanId) {
IntStream.rangeClosed(1, 3).boxed()
.forEach(x -> passOneYear(humanId));
}
//1살의 나이와 10cm의 키성장을 한다.
@Transactional
public void passOneYear(Long humanId) {
Human human = humanRepository.findById(humanId).get();
human.growOld();
human.growHeight();
}
}
아래 테스트 코드는 성공할까?
@SpringBootTest
class HumanHandlerTest {
@Autowired
private HumanHandler humanHandler;
@Autowired
private HumanRepository humanRepository;
@Test
void name() {
Human born = Human.born(); // age: 1, height: 10
Human savedHuman = humanRepository.save(born);
humanHandler.liveLife(savedHuman.getId());
Human human = humanRepository.findAll().get(0);
Assertions.assertThat(human.getAge()).isEqualTo(40);
Assertions.assertThat(human.getHeight()).isEqualTo(40);
}
}
결과는 '실패'이다. human은 여전히 'age: 1, height: 10'이다
원인은 passOneYear() 메서드에 달려있는 @Transactional 어노테이션이 동작하지 않기 때문이다.
그래서 human 객체에 대한 변경 감지가 이뤄지지 않고 human의 값을 수정해도 update쿼리가 날아가지 않는다.
passOneYear() 메서드에 대해 @Transactional이 동작하지 않는 이유가 궁금하다면 아래 글을 참고하길 바란다.
그렇다면 어떻게 해야 하는가?
어떻게 코드를 수정하면 테스트를 통과할 수 있을까?
방법 중 하나는 liveLife() 메서드에 @Transactional을 달아주는 것이다.
@RequiredArgsConstructor
@Service
public class HumanHandler {
private final HumanRepository humanRepository;
//@Transactional이 추가되었다
@Transactional
public void liveLife(Long humanId) {
IntStream.rangeClosed(1, 3).boxed()
.forEach(x -> passOneYear(humanId));
}
public void passOneYear(Long humanId) {
Human human = humanRepository.findById(humanId).get();
human.growOld();
human.growHeight();
}
}
이제 passOneYear()은 부모 트랜잭션인 liveLife()의 트랜잭션안에서 동작한다.
그리고 liveLife의 트랜잭션이 종료될 때 변경 감지를 통해 update쿼리가 발생한다.
(아래 이미지를 참고하자)
**transactional debug는 application.yml에 아래와 같이 설정하면 된다.
logging:
level:
org.springframework.orm.jpa: DEBUG
여기서 필자는 passOneYear()이 한 번 돌 때마다 커밋을 하고 싶었고 propagation을 활용해보기로 했다.
문제의 코드는 여기서부터 시작이다.
문제의 코드
@RequiredArgsConstructor
@Service
public class HumanHandler {
private final HumanRepository humanRepository;
@Transactional
public void liveLife(Long humanId) {
IntStream.rangeClosed(1, 3).boxed()
.forEach(x -> passOneYear(humanId));
}
// Propagation.REQUIRES_NEW를 추가해주었다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void passOneYear(Long humanId) {
Human human = humanRepository.findById(humanId).get();
human.growOld();
human.growHeight();
}
}
@Transactional에 Propagation.REQUIRES_NEW를 달아주면 부모 트랜잭션과 독립적으로 새로운 트랜잭션을 시작한다.
즉 passOneYear()이 호출될 때 독립적인 새로운 트랜잭션을 시작하고 passOneYear()이 끝날 때 새로 시작했던 트래잭션이 종료되면서 커밋을 한다. 아니, 그렇다고 한다.
하지만 실제 동작은 liveLife()에만 트랜잭셔널을 달아주었던 기존 동일하게 모든 로직이 끝나고 커밋이 진행되었다.(왜지...?)
여러분은 혹시 원인이 한눈에 보이는가?
문제의 원인
원인은 '들어가기에 앞서...' 파트에서 제시했던 코드와 동일하다.
동일한 클래스에서 내부 호출하는 메서드에는 @Transactional이 동작하지 않는 것이 문제였다.
아래와 같이 하면 의도대로 동작한다.
새로운 HumanService를 생성 후 passOneYear() 위치
@RequiredArgsConstructor
@Service
public class HumanService {
private final HumanRepository humanRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void passOneYear(Long humanId) {
Human human = humanRepository.findById(humanId).get();
human.growOld();
human.growHeight();
}
}
Human Handler에서 HumanService의 passOneYear()을 호출
@RequiredArgsConstructor
@Service
public class HumanHandler {
private final HumanService humanService;
@Transactional
public void liveLife(Long humanId) {
System.out.println("call liveLife");
IntStream.rangeClosed(1, 3).boxed()
.forEach(x -> humanService.passOneYear(humanId));
}
}
이제 다시 테스트를 실행해보자
의도대로 동작한다.
결과 정리
- @Transactional 어노테이션은 클래스 내부 호출할 경우 동작하지 않는다.
- 부모와 자식 메서드에 모두 @Transactional 어노테이션이 달려있을 때 자식 메서드가 트랜잭션 안에서 동작하는 것은 자식 메서드 본인에게 달린 @Transactional 어노테이션 때문이 아니라 부모 메서드에 달린 어노테이션 때문이다. 이를 혼동하지말자
회고
사실 쉬운 문제를 두고 바보처럼 삽질을 하긴 했지만 이번 기회를 통해 @Transacational에 대해서 심도 있게 공부하는 계기가 되어서 얻은 건 많은 경험이었다.
**참고
https://kafcamus.tistory.com/33?category=912020