이글을 공부 목적으로 작성된 글입니다. 왜곡된 정보가 포함되어 있을 수 있습니다.
계기
처음 스프링 부트를 공부할떄, @Transactional에 대해서 아주 심플하게 이해했다.
"db에 접근하기 위해서 @Transactional을 사용해야한다. 데이터에 변경이 @Transactional 써야한다. 만약 데이터를 단순히 조회하기 위해서는 readonly=true, 데이터를 변경하는 경우는 readonly=true를 써야한다,"
특히나 repository에서는 사용하지 않고, service에서만 @Transactional를 사용하는 것을 보고 service에서만 사용하는 것이구나 라고, 경험적으로 생각했었다.
처음 스프링부트로 계발을 해보면 @Transactional를 깜박하는 실수를 많이 하곤 했다. 그러나 종종 다음과 같은 상황을 경험한 자도 있을 것이다.
"@Transactional 빼먹었네 그런데 왜 되지?"
본인의 경우에도 사이드 프로젝트진행중 팀원이 나의 @Transactional 지적 해주신 덕분에 내가 @Transactional를 아무것도 모르고 썼던 것을 알게되었다.
문제상황
다음은 실제로 내가 구현했던 코드이다.
@Service
public UserService{
//중략
@Transactional
public User joinDisable(OAuth2User oAuth2User, Map<String, String> signupMap, Role role) {
Health health = new Health(Integer.parseInt(signupMap.get("see")), Integer.parseInt(signupMap.get("walk")),..);
//중략
User user = new User(Objects.requireNonNull(oAuth2User.getAttribute("name")), Objects.requireNonNull(oAuth2User.getAttribute("email")), UUID.randomUUID().toString(), signupMap.get("phoneNum"), role, health);
healthRepository.save(health);
userRepository.save(user);
return user;
}
}
해당 로직을 설명하면 Disable type의 인원이 회원가입 할때, 실행되는 메소드로 이미 회원가입 여부는 판단되어 있는 상태로 엔티티를 저장해주면 된다.
User와 Health 엔티티가 저장되는데 둘은 일대일 연관관계를 가진다.
여기서 문제가 두가지 있었는데
- @Transactional을 제거해도 정상적으로 save 되었음
- 코드 리뷰 중 @Transactional 제거하자는 의견이 제시 되었음
항상 서비스 단에서 변경이 일어나면 @Transactional을 사용하고 그렇지 않으면 readOnly를 추가하고 @Transactional에 대한 이해가 없던 상황이여서 여러 가지 테스트를 진행해보았다.
로직
여러가지 crud 상황중 저장하는 상황에서 @Transactional을 살펴보자
다음 클래스가 존재한다.
- Posting : 엔티티 클래스로써 게시글에 해당한다. (여러가지 연관관계가 필요한데 프로토타입으로 아직 연관관계가 존재하지 않고, 연관관계가 필요한 것들을 String으로 대체 했다)
- PostingRepository: 레포지토리로써 Posting의 crud 기능을 지원한다.
- PostingService: 서비스로써 Posting과 관련된 비즈니스 로직을 구현한다. (일반적인 @Transactional 선언부)
- PostingController: 컨트롤로써 Posting 관련된 RestController를 지원한다.
아직 @Transactional에 대한 이해가 없는 사람이라는 아래의 테스트 결과를 예측하고 결과를 본다면 더 좋은 학습이 될 것이다.
@Transactional을 선언하지 않는 경우
@RestController
@RequiredArgsConstructor
public class PostingController {
private final PostingService postingService;
@PostMapping("/posting")
public Posting savePosting(@RequestParam("name") String name){
return postingService.savePosting(name);
}
}
PostingController
@Service
@RequiredArgsConstructor
public class PostingService {
private final PostingRepository postingRepository;
public Posting savePosting(String name){
Posting posting=new Posting();
posting.setAuthor(name);
postingRepository.savePosting(posting);
return posting;
}
}
PostingService
@Repository
@RequiredArgsConstructor
public class PostingRepository {
private final EntityManager entityManager;
public Posting savePosting(Posting posting){
entityManager.persist(posting);
return posting;
}
public Posting findById(Long id){
return entityManager.find(Posting.class, id);
}
}
PostingRepository
POST "/posting" 을 호출하면 결과가 어떻게 될까?
결과: 500 code
서버 내부에서는 No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call 라는 트랜젝션을 사용할 수 있는 EntityManager가 존재하지 않는다는 에러 메세지를 확인 할 수 있다.
데이터베이스 상태를 변화 시키는 단위가 트랜젝션이 존재하지 않기 때문에 에러가 발생하는 것이다.
@Transactional을 Service에 선언하는 경우
@RestController
@RequiredArgsConstructor
public class PostingController {
private final PostingService postingService;
@PostMapping("/posting")
public Posting savePosting(@RequestParam("name") String name){
return postingService.savePosting(name);
}
}
PostingController
@Service
@RequiredArgsConstructor
public class PostingService {
private final PostingRepository postingRepository;
@Transactional
public Posting savePosting(String name){
Posting posting=new Posting();
posting.setAuthor(name);
postingRepository.savePosting(posting);
return posting;
}
}
PostingService
@Repository
@RequiredArgsConstructor
public class PostingRepository {
private final EntityManager entityManager;
public Posting savePosting(Posting posting){
entityManager.persist(posting);
return posting;
}
public Posting findById(Long id){
return entityManager.find(Posting.class, id);
}
}
PostingRepository
이전 코드에서 service에 @Transactional 어노테이션을 추가했다.
POST "/posting" 을 호출하면 결과가 어떻게 될까?
결과: 200 code
service의 savePosting 함수에 @Transactionald으로 인해 하나의 Transaction으로 실행되어 해당 메소드 내부에서는 ACID가 보장된다.
@Transactional(readOnly = true) 을 Service에 선언하는 경우
@RestController
@RequiredArgsConstructor
public class PostingController {
private final PostingService postingService;
@PostMapping("/posting")
public Posting savePosting(@RequestParam("name") String name){
return postingService.savePosting(name);
}
}
PostingController
@Service
@RequiredArgsConstructor
public class PostingService {
private final PostingRepository postingRepository;
@Transactional(readOnly = true)
public Posting savePosting(String name){
Posting posting=new Posting();
posting.setAuthor(name);
postingRepository.savePosting(posting);
return posting;
}
}
PostingService
@Repository
@RequiredArgsConstructor
public class PostingRepository {
private final EntityManager entityManager;
public Posting savePosting(Posting posting){
entityManager.persist(posting);
return posting;
}
public Posting findById(Long id){
return entityManager.find(Posting.class, id);
}
}
PostingRepository
이전 코드에서 service에 @Transactional(readOnly=true) 어노테이션을 추가했다.
POST "/posting" 을 호출하면 결과가 어떻게 될까?
결과: 200 code
readOnly=true 이기 때문에 읽기만 하고 insert는 할 수 없는 것 아닌가라고 처음에 생각했다.
readOnly=true 의 경우 flush가 일어나지 않아 결국 db에 반영이 되는 않는 것 아닌가라고 생각했다.
Test 코드를 작성해보자
@SpringBootTest
public class TransactionalTest {
@Autowired
public PostingService postingService;
@Rollback(value = false)
@Test
public void test1(){
postingService.savePosting("test");
}
}
결과: Connection is read-only. Queries leading to data modification are not allowed
read-only 커넥션이므로 데이터 변경이 허용되지 않는다는 에러가 발생하였다.
어떻게 실제 요청을 보낸 결과와 테스트 코드 결과가 다를까?
결과가 다른 이유를 찾지 못하였는데 가장 유력한 2가지 가설이 있다.
1. 실제 프로젝트 실행환경과 테스트 환경의 차이
위 readOnly와 별개 프로젝트 실행 환경과 테스트 환경이 다른것 같다.
정확하게는 기억이 나지 않는데 다른 프로젝트를 진행하면서 실제 컨트롤러에서는 문제가 없었는데 테스트 코드에서 문제가 생겼던 경험이 있었다.
구글링을 오래했는데 아직 나의 지식 수준으로는 해결할 수 없었다.
2. spring boot 에서의 readOnly
구글링중 발견한 것인데, 일단 Mysql에서는 readOnly Transaction을 제공하고 있었는데, 이것이 실제 @Transactional 어노테이션의 readOnly이 이것은 전부 따르지 않는 것 같다.
https://docs.spring.io/spring-data/relational/reference/jdbc/transactions.html
spring 문서에서 확인된 것인데, readOnly=true으로 설정해준다 한들, 이것이 해당 쿼리를 트리거하지 않는 것을 확인하지 않는다는 것이다. 힌트를 준다는 표현을 쓰고 있다.
그러니까 readOnly=true이여도 insert가 발생할 수 도 있다는 것이다.
이렇게 구현한 이유가 이해가 되지 않았는데, 이것도 Spring 내부 아키텍쳐에 대한 깊은 이해가 필요한 것 같아서 전부 이해할 수 없었다.
찾아봐도 같은 말만 하고 있다.
정리:
- readOnly=true가 변경 금지를 보장하지 않는다.
- 그래도 readOnly=true을 조회 로직에 사용해야 하는 것은 확실하다.
- 프로젝트 실행과 테스트 환경사이에 미세한 간극이 존재한다.
'Java > 스프링 부트' 카테고리의 다른 글
[JMeter] JMeter를 통해 테스트 (0) | 2024.06.17 |
---|---|
[Webflux] Publisher, Subscriber (0) | 2024.06.05 |
[Webflux] Mono, Flux.Block() (0) | 2024.06.05 |
[Webflux] Reactor 시작하기 (0) | 2024.03.25 |
[Spring boot] @ConfigurationProperties 에서 "Failed to bind properties" (0) | 2024.03.06 |