본문 바로가기
개발잡담

Transaction은 뭐고 @Transactional은 언제 쓰는 건데?

by 행복한집사 2021. 12. 26.

최근 계좌이체 같은 고전적인? 기능에 대해 고민할 일이 있었다. 계좌이체를 그대로 해보는 건 재미없으니까 현재 진행하고 있는 프로젝트에 필요한 ‘추천 영화에 하트 주기 기능’을 구현하면서 테스트 작성한 것을 포스팅 해봐야겠다고 생각했다.

1. Transaction 이란?

트랜잭션이란 논리적인 작업의 단위다. 하나의 작업, 트랜잭션을 수행하기 위해서는 여러 쿼리들이 수행될 수 있는데 데이터베이스의 안정성을 위해 이 쿼리들은 모두 수행되거나 중간에 문제가 생긴다면 아예 모두 수행되지 않아야 한다. 예를 들어 계좌이체처럼 말이다. A계좌에서는 돈이 나갔는데 B계좌로 돈이 안 들어가면 그건 큰 문제니까!

스프링에서는 트랜잭션을 보장하기 위해 @Transactional 어노테이션을 사용한다. @Transactional 이 어노테이션에는 여러 속성들이 있다. 그 중 isolation level(격리 수준)을 옵션으로 설정할 수 있다. 예를 들어
@Transactional(isolation = Isolation.READ_COMMITTED)
이렇게 지정해줄 수 있다. 지정하지 않는다면 DB의 isolation level을 따라간다.
내가 쓰는 DB의 현재 isolation level은 Repeatable read이다.
Repeatable read: 트랜잭션이 시작되고 나서 조회한 데이터를 다른 트랜잭션이 변경하거나 삭제가 불가능한 격리수준이다.

(그 동안 트랜잭션 격리 수준을 크게 고민해 본적은 없는데… 새해에 행복회로를 돌려보자면 내가 만든 서비스를 많은 사람이 사용해서 이런 고민을 진지하게 해보게 되었으면 좋겠음)

2. 추천 영화에 하트 주기 기능 테스트

CASE> 사용자가 회원가입을 하면 영화 추천 시 사용할 수 있는 하트를 100개 나눠 주기로 했다.
사용자가 어떤 영화를 하나 추천할 때마다 본인이 갖고 있는 하트 개수 안에서 추천 영화에 하트를 줄 수 있다.
즉 가진 하트 갯수보다 더 많은 하트 갯수를 배분할 수가 없다는 뜻이다.

그런데 사용자가 가진 하트 갯수보다 더 많은 하트를 주려고 한다면?
예외가 발생하고 하트 배분은 물론 영화 추천내역 저장도 실패해야 할 것이다.
영화 추천이 실패하면 사용자가 가진 하트도 그대로 있어야 한다.

그래서 아래와 같이 영화 추천내역을 저장하는 save 메소드를 저장하고 save 메소드 내 영화 추천 하트를 사용자가 가진 추천 하트 갯수에서 빼는 메서드를 호출하게 했다. 만약 사용자가 가진 하트가 주는 하트보다 적으면 에러가 나게 된다. @Transactional 어노테이션은 영화 추천 기능의 트랜잭션을 보장한다.

@Service @Transactional @RequiredArgsConstructor public class RecommendService { private final RecommendRepository recommendRepository; private final MemberRepository memberRepository; public Long save(Member member, Recommendation recommendation){ Member findMember = memberRepository.find(member.getId()); consumeHeart(findMember, recommendation.getUserHeart()); Long id = recommendRepository.save(recommendation); return id; } private void consumeHeart(Member member, long heart){ long mHeart = member.getHeart(); if(mHeart - heart < 0){ throw new RuntimeException("Not enough hearts left"); } member.setHeart(mHeart-heart); }

트랜잭션이 정말 보장되는지 아래처럼 테스트를 작성해봤다.
영화 추천이 성공적으로 이루어지는 메서드 saveRecommendation_pass와 실패하는 테스트 케이스 saveRecommendation_error를 작성했다.

@ExtendWith(SpringExtension.class) @SpringBootTest class RecommendServiceTest { static private Member member; static private Recommendation recommendation_pass; static private Recommendation recommendation_fail; @Autowired UserService userService; @Autowired RecommendService recommendService; @BeforeAll static void beforeAll(@Autowired UserService userService){ member = new Member(); //필수값 설정 member.setEmail("test@test.com"); member.setPassword("1212"); member.setHeart(100); userService.joinUser(member); recommendation_pass = new Recommendation(); recommendation_fail = new Recommendation(); } @Test @DisplayName("영화 추천 하트 배분 성공") void saveRecommendation_pass() throws Exception{ recommendation_pass.setUserHeart(100); Long recommendId = recommendService.save(member, recommendation_pass); //db에서 다시 조회한 내역을 비교 Member aftMember = userService.find(member.getId()); Recommendation aftRecommendation = recommendService.find(recommendId); assertEquals(0L, aftMember.getHeart()); assertEquals(100, aftRecommendation.getUserHeart()); } @Test @DisplayName("영화 추천 하트 배분 실패") void saveRecommendation_error() throws Exception{ recommendation_fail.setUserHeart(101); RuntimeException re = assertThrows(RuntimeException.class, () -> recommendService.save(member, recommendation_fail)); assertThat(re.getMessage()).isEqualTo("Not enough hearts left"); } }

테스트 메서드를 하나씩 테스트 해보자.

일단 두번째 메서드를 수행하면 하트가 충분하지 않다는 에러가 나야 한다. 사용자가 주려는 하트 개수 101이 현재 사용자가 갖고 있는 하트 개수 100보다 크기 때문이다. 에러가 나면 사용자가 가진 하트 개수는 차감되지 않고 그대로 100으로 있어야 하며 영화 추천내역은 생성되지 않아야 한다.

테스트는 성공했고 예상대로 DB에 반영되어 있는지 실제 DB를 조회해봤다.

예상대로 member의 heart는 100이고 생성된 recommendation(영화 추천내역)은 없다.

그 다음 첫번째 메서드를 수행해보자. 아래와 같이 새로 만든 멤버의 추천 하트 개수가 0이 되고 영화 추천내역이 받은 하트 개수는 100이 되어야 한다.

역시 테스트는 성공했고 DB에서 다시 한 번 데이터를 확인했다.

예상대로 member의 heart는 0이고 생성된 recommendation의 heart는 100이다.


cf) 참고로 이 테스트를 할 때는 테스트 클래스에 @Transactional을 붙이지 않았다 .
실제 Service를 작성할 때는 까먹고 @Transactional을 안 붙였는데 테스트 코드에만 @Transactional을 붙이면 테스트는 통과하고 실제로 서비스할 때는 오류가 생길 수 있겠다는 생각이 들었기 때문이다.
(테스트 클래스에 @Transactional을 붙이면 테스트 끝나고 자동적으로 Rollback이 되어 테스트를 좀 더 편하게 할 수 있다)



이상하거나 궁금한 점이 있다면 댓글로 알려주세요~

댓글