이번 포스트는 동시성 제어 테스트 코드를 작성하다 만난 문제들을 해결해 가면서 얻은 내용들을 소개 해 보도록 하겠습니다.
@SpringBootTest
public class StoreLikeServiceTest {
@Autowired
private StoreLikeRepository storeLikeRepository;
@Autowired
private StoreService storeService;
@Test
void testConcurrentLikeIncrease() throws InterruptedException {
// given
Store postLike = storeLikeRepository.save(new Store(1L, 0L));
System.out.println("store의 id"+postLike.getId());
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(20);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
storeService.increase(postLike.getId());
//postLike.increase();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
System.out.println("HI");
Store findViewCount = storeLikeRepository.getReferenceById(postLike.getId());
System.out.println(findViewCount.getLikeCount());
// then
assertThat(findViewCount.getLikeCount()).isEqualTo(100);
}
}
@Transactional
public void increase(Long storeLikeId) {
try {
Store store = storeLikeRepository.findById(storeLikeId)
.orElseThrow(() -> new NoSuchElementException("Store not found with id: " + storeLikeId));
store.increase();
} catch (NoSuchElementException e) {
System.err.println("Error: " + e.getMessage());
}
}
public void increase() {
this.likeCount += 1;
System.out.println("증가");
}
해당 flow를 설명해보자면,
그런데 이렇게 코드를 실행시키면,
no session이라는 오류가 뜬다.
분명 해당 오류는 JPA공부하면서 마주했던거 같았는데, 기억이 가물가물했던거 같다.
해당 블로그를 보면서 알았는데,
Member와 Message가 1:N 관계를 가지고 있다고 하자.
@GetMapping("~")
public MemberDto getPost(~){
Member member = memberServcie.getMember(id);
return new MemberDto(member);
}
이렇게하면 Could not initialize proxy - no Session오류가 발생한다
왜일까?
여기서 Member를 조회하면, Lazy Loading으로 연관된 Message는 프록시 객체로 채워진다.
실제 필요할때 쿼리를 날려서 프록시를 진짜로 채우는 과정을 거친다.
memberServcie에서 getMember메서드는 @Transactioanl 범위에 존재한다. 왜냐하면 MemberService에다가 @Trasactional을 걸었기 때문이다.
그런데, 문제는 스프링 컨테이너는 트랜잭션의 범위의 영속성 컨텍스트 전략을 기본으로 사용한다는 것이다.
이말은, 트랜잭션을 시작할때 영속성 컨텍스트를 시작하고, 트랜잭션이 종료될 때 영속성 컨텍스트가 종료된다는 것이다.
그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.
그래서 코드를 보면
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
//code
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
Member member = new Member();
member.setId(1L);
member.setName("godong");
em.persist(member);
tx.commit();
}catch (Exception e){
tx.rollback();
}finally {
em.close();
emf.close();
}
}
}
엔티티 매니저로부터 transactioanl을 얻은다음에, tx.begin() 호출후 -> 데이터 변경 -> persist하고 -> tx.commit()을 치는것을 볼 수 있다.
그럼 아까 문제를 어떻게 해결하면 될까?
간단하게, Service에서 Member를 -> DTO로 변환하는 과정을 거치면된다.
@GetMapping("~")
public MemberDto getPost(~){
return memberServcie.getMemberDTO(id);
}
public MemberService{
...
@Transactional
public MemberDto getMemberDTO(Long id){
Member member = memberRepository.findById(id);
return makeMemberDTO(member);
}
}
이렇게 되면 memberRepository에서는 findById를 통해서 member에 프록시를 가져오지만, 영속성 컨텍스트에 올려두고 아직 getMemberDTO메서드가 끝난게 아니므로, 해당 세션을 통해서 makeMemberDTO에 필요한 Message들을 쿼리를 날려서 프록시를 진짜로 대체하게 된다.
참고
OSIV를 true로 설정하면 이렇게 안해도 에러가 발생하지 않는다.
이유는 컨텍스트의 라이프 사이클이 요청 종료시점까지 소멸되지 않기 때문이다.
Open Session In View가 설정되면 컨트롤러 요청시점에 영속성 컨텍스트가 시작되어 트랜잭션 종료시점에 영속성 컨텍스트가 소멸하지 않는다.(즉 memberService.getMember(id)를 호출해서 서비스레이어에서 트랜잭션이 종료되도 영속성 컨텍스트는 소멸되지 않는다.)
그렇기 때문에 Service 레이어에서 리턴 받은 값을 Controller에서 조회하는 과정에서 영속성 컨텍스트가 유지되기 때문에 값을 가져올 수 있어 에러가 발생하지 않을수 있다.
그러나, 나는 현재 테스트 코드에서 연관관계 매핑한것도 없는데 왜 이런 결과가 나왔는지 의문이 들었다.
테스트 코드에서 @Transactional을 명시적으로 사용하지 않아도, storeLikeRepository.save()가 호출되면 스프링 Data JPA는 내부적으로 필요한 최소한의 트랜잭션을 생성하고 관리한다.
save 메서드가 호출될때, 스프링은 해당 메서드가 DB에 접근하고 변경작업을 수행한다고 간주한다. 고로, 새로운 트랜잭션을 자동으로 생성하여 save메서드를 실행한다.
이때, 트랜잭션의 범위는 save()메서드 한정이다. 고로, 해당 save()메서드가 완료되면, 자동으로 커밋되거나 롤백되며, 영속성 컨텍스트는 변경된 데이터를 DB에 반영하게 된다.
문제는 getReferenceById에 있었다. getReferenceById()는 EntityManager.getReference를 사용하는데 해당 메서드는 실제 객체가 아닌 Proxy객체(엔티티의 참조)를 가져오게 되며, 실제 객체의 속성에 접근할때 DB에 쿼리를 날려 접근하게 됩니다.
그런데 문제는, Proxy 객체에 실제 데이터를 불러오려고 초기화를 시도해도, 현재 Transactional이 없기 때문에 값을 가져올 수 가 없습니다.
왜냐하면, TestCode에서 Transactional 설정을 하지 않았기 떄문입니다.
고로, 지연로딩을 하려면 객체를 무조건 영속성 컨텍스트에서 관리해야합니다.
그래야 필요할떄 영속성 컨텍스트에서 DB한테 초기화 요청을 할 수 있기 때문입니다.
영속성 컨텍스트는 트랜잭션 내에서 수행되어야하기 때문에, Test Code에 Transactional을 붙이면 Test code 레이어에서
하나의 Transaction이 실행되고, save를 하면 영속성 컨텍스트에 올라가고, 해당 영속성 컨텍스트에서 getReferenceById할때 캐시에서 가져오므로 정상적으로 로직이 수행됩니다.
그래서 해결방안은, TestCode에 @Transactional애노테이션을 붙여서 해당 TestCode가 끝날때까지 영속성 컨텍스트가 Store엔티티를 관리하게 해주는것입니다.
또 다른방법은, getReferenceById대신에 findById()같은 조회 메서드를 사용하는 것입니다.
왜냐하면, findById()같은 메서드는 트랜잭션이 없더라도 실행할 수 있습니다. 이경우, 스프링이 DB에다가 직접 쿼리를 날려 데이터를 조회합니다.
그러나, 해당 경우는 Transactional내에서 수행되는것이 아니므로, 데이터 일관성이 보장되지 않거나, 조회 결과가 최신 상태가 아닐 수 있습니다.
고로, 트랜잭션 내에서 실행하는게 권장되지만, 일단 오류는 나지 않는다는 것입니다.
그래서 저는 TestCode에 @Transactional을 붙여주게 되었습니다.
현재 서비스 레이어와 Test 레이어에 둘다 @Transactional을 붙여주었습니다.
그런데, 해당 코드를 실행시켜보면 increase메서드 호출시 해당 로직에서
이런 오류가 발생하였습니다.
@Transactional
public void increase(Long storeLikeId) {
try {
Store store = storeLikeRepository.findById(storeLikeId)
.orElseThrow(() -> new NoSuchElementException("Store not found with id: " + storeLikeId));
store.increase();
} catch (NoSuchElementException e) {
System.err.println("Error: " + e.getMessage());
}
}
여기서 storeLikeRepository.findById 호출시 해당하는 Store가 없다는 것입니다.
Test code를 다시 확인해보면,
@Test
@Transactional
void testConcurrentLikeIncrease() throws InterruptedException {
// given
Store postLike = storeLikeRepository.save(new Store(1L, 0L));
System.out.println("store의 id"+postLike.getId());
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(20);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
storeService.increase(postLike.getId());
//postLike.increase();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
System.out.println("HI");
Store findViewCount = storeLikeRepository.findById(postLike.getId()).orElseThrow();
System.out.println(findViewCount.getLikeCount());
// then
assertThat(findViewCount.getLikeCount()).isEqualTo(100);
}
현재 save메서드 호출시, @Transactional이 걸려있으므로 영속성 컨텍스트에 Store 1번을 집어넣고, for문을 돌면서, increase()메서드를 호출하게 됩니다.
그러면, 영속성 컨텍스트에 있는 Store1번을 그냥 가져다 쓰면 되는데 도대체 왜 안되는지 이해가 되지 않았습니다.
그래서 혹시, @Transactional이 서비스 단에도 걸려있으니까, 이게 혹시 문제가 될까? 싶어서
Transactional 전파를 한번 다시 리마인드 해봤습니다.
트랜잭션을 수행하고 있는도중에(아직 커밋이나 롤백을 하지 않은경우) 또다른 트랜잭션을 내부에서 수행한다면, 스프링은 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션으로 만들어줍니다.
그러면, 서비스 레이어의 increase메서드는 기존의 Test 메서드의 트랜잭션에 참여하니까 TestCode에서 영속성 컨텍스트에 올려둔 Store 1 엔티티를 가져다 쓸 수 있어야하는데 왜 안되지? 라는 생각이 들었습니다.
전파에 대한 자세한내용은 여기를 눌러주세요.
그래서 구글링에 Test Code @Transactional을 검색하여서 공부를 해보았습니다.
Test Code에다가 @Transactional을 붙이는 이유는 크게 두가지 이점을 누릴 수 있기 때문입니다.
테스트 통과/실패 시 트랜잭션을 롤백해주는 기능
JPA 영속성 컨텍스트의 범위를 테스트 레이어까지 확장하여, 지연 로딩 전략으로 설정된 연관관계 엔티티들을 테스트 코드에서 조회할 수 있게 해주는기능
2번째 기능은 제가 앞에서 @Transactional을 TestCode를 붙이지 않아서 만난 문제와 같습니다.
그러나 해당 기능은 논란도 많고, 몇가지 문제가 있습니다.
문제 1
서비스 코드에는 @Transactional이 없고, 테스트 코드에는 @Transactionaㅣ이 있는경우,
만약 Test Code를 실행한다면, 정상적으로 Test가 통과 합니다. 왜냐하면, 실제 프로덕션 레이어에 트랜잭션이 없음에도 불구하고, 테스트 레이어에서 생성한 트랜잭션을 이용해 작업을 수행하였기 때문에 테스트가 정상적으로 통과 할 수 있는 것 입니다.
만면에, 운영환경에서는 @Transactional이 없기 떄문에, 만약 서비스 메서드에서 아까 Test Code를 실행한다면, findById()는 트랜잭션이 없어도 동작하니까, 일단 쿼리를 날려서 가져오지만 연관된 엔티티는 어차피 프록시로 가져오게 됩니다.
그래서 연관엔티티에 접근하려고할때, 하이버네이트가 데이터베이스 쿼리를 보내려고하지만, 트랜잭션이 없으면 LazyInitializationException오류가 발생하는 것입니다.
문제 2
또는 이런 상황이 있을 수 도 있습니다.
만약, 서비스 레이어와, TestCode 둘다 @Transactional을 붙였지만, SQLintegrityConstraintViolationException이 발생하는 경우도 있습니다.
예를 들어, 회원가입하는 로직이 있다고 가정해봅시다.
@Entity
public class 회원{
,,,
public void 이름바꾸기(String 바꿀이름){
this.이름 = 바꿀이름;
}
}
@Test
@Transactional
void test(...){
...
회원Repository.save(회원);
회원.이름바꾸기("새로운이름");
}
이러한 경우 insert쿼리는 날라가고, JPA에서 지원하는 변경감지로 UPDATE 쿼리가 안날라가 갈 수도 있습니다.
어? Test Code에 트랜잭션이 적용되어있으므로, 트랜잭션 안에서 발생하는 엔티티 변경은 마지막에 update쿼리를 날려야하는데 왜 update작업을 하지 않는 것일까요?
서비스 레이어에 적용된 트랜잭션은 작업이 끝날때 트랜잭션을 커밋하거나 롤백하는것이 기본으로 되어있지만,
테스트 레이어에 적용된 트랜잭션은 각 테스트가 끝날 때 트랜잭션을 롤백하는 것을 기본으로 적용하고 있습니다.
그런데, JPA 변경감지는 트랜잭션을 커밋하는 시점에에 변경 내용을 SQL로 바꾸어 DB에 반영하는 원리이므로, 롤백이 기본인 테스트 코드에서는 insert나 update쿼리로 데이터베이스에 반영하는 작업을 하지 못하고 1차캐시 내용이 그대로 휘발되는것입니다.
만약 save메서드로 저장을 한뒤에, 회원.이름바꾸기("새로운이름");를 통해서 Update쿼리를 전송할때, 외래키 제약조건과 같은 예외가 발생할 수 있는 코드였는데,
테스트 하는 메서드에 @Transactional이 붙어있으면 1차 캐시에 있는 내용을 SQL로 바꾸어 반영하는 Commit작업을 하지 않고 롤백을 수행하므로, 테스트 코드에서 해당 Update쿼리가 발생하지 않고, 테스트가 정상적으로 통과되는것으로 처리 될 수 있습니다.
여기까지하고, 어? 저는 왜? 그러면 save메서드 호출시 insert쿼리는 발생하는거지? 라는 의문이 들었습니다.
Store를 save할때 insert쿼리문이 나가기 때문입니다.
insert도 쓰기 지연 저장소에 저장되었다가 커밋이 되야 나가는건데, 이건 왜 떳지 라는생각이 들었습니다.
그이유는, 바로, MySQL을 썻기 때문입니다. 이거 찾느라 고생좀 했습니다. ㅠㅠ
MySQL의 경우 AUTO_INCREMENT를 사용한 키 채번을 사용한다면, DB에 실제 INSERT를 호출해야지만 Key값을 알 수 있습니다. 그래서 insert쿼리가 발생하는 것입니다.
근데 이런 방식은 테스트에 @Transactional을 붙였을 때 누릴 수 있는 모든 이점을 포기하겠다는 방식인데,
그러면 @Transactional이 제공하던 지연 로딩 엔티티 조회 기능과 데이터 롤백 기능을 직접 만드는 수밖에 없습니다.
그러면 각 테스트 케이스 실행 전/후에 Junit 5의 @BeforeEach, @AfterEach를 통해서 deleteAll()등을 호출하거나 해야할 것입니다.
또한, 지연로딩을 실행하기위해서는 fetchjoin등을 활용하여서 연관된 엔티티를 전부 끌고오는 수밖에 없습니다.
그러면 어떻게 하라는거냐? @Transactional을 조심히 사용하자는 거다.
@Test
@Transactional
void test(...){
...
회원Repository.save(회원);
회원.이름바꾸기("새로운이름");
entityManager.flush();
}
entityManager를 가져온 후에 flush()메서드를 호출하여서 1차 캐시의 내용을 DB에 반영하는 작업을 강제하도록 만든다.
고로, 검증대상이 동일하더라도, e2e테스트, http api 테스트 등의 통합 테스트를 추가로 작성하고 검증하면서 슬라이스 테스트 뿐만 아니라 최대한 다양한 관점에서 테스트 코드를 작성하는것이 중요합니다.
이렇게 TestCode에서 @Transactional에 대해서 알아보았습니다.
그런데, 저는 TestCode와 서비스 둘다 @Transactional을 붙여서, 트랜잭션 범위 확장시켜줬고, LazyLoadingException같은 것도 아니고, 그냥 영속성 컨텍스트에서 가져와서 필드 값만 바꾸면 되는건데 도대체 왜 안되는 걸까요?
그 이유는 Test Code의 범위와 서비스에서 increase()메서드를 호출하여서 로직을 수행하는 범위가 다르기 때문이다.
테스트를 진행하는 스레드는 main 스레드이다.
그리고 executorService에서 실행되는 코드는 ExecutorService가 생성한 스레드에서 실행이된다.
여러 스레드에서 EntityManagerFactory는 접근이 가능하다. 왜냐하면 팩토리 자체를 여러개 만드는것은 자원을 너무 크게 소모하기 떄문이다.
그러나, 팩토리에서 만드는 EntityManger는 한번 쓰면 버려야하고, 여러 스레드가 동시 접근이 안된다.
왜? 안될까? 상식으로 생각해보면 내가 스레드 1에서 member1을 수정하고 있는데 다른 스레드에서 접근해서 member1을 지워버리면 안될것이다. 고로, 엔티티 매니저는 하나를 공유하면 안되고, 상황에 따라서 계속 만들어야한다.
이를 생각해보면,
@Test
void testConcurrentLikeIncrease() throws InterruptedException {
// given
Store postLike = storeLikeRepository.save(new Store(1L, 0L));
System.out.println("store의 id"+postLike.getId());
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(20);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
storeService.increase(postLike.getId());
//postLike.increase();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
System.out.println("HI");
Store findViewCount = storeLikeRepository.findById(postLike.getId()).orElseThrow();
System.out.println(findViewCount.getLikeCount());
// then
assertThat(findViewCount.getLikeCount()).isEqualTo(100);
}
Store postLike = storeLikeRepository.save(new Store(1L, 0L));를 통해서 영속성 컨텍스트에 Store 1엔티티를 올려두는것은 메인스레드의 영속성 컨텍스트이다.
그러나 for문을 돌면서 increas()메서드를 호출하는것은, ExecutorService가 생성한 스레드에서 실행이 된다.
고로, main스레드의 EntityManager와 executorService가 실행하는 스레드의 엔티티매니저는 서로 다른 1차캐시를 가지고있다.
그러므로, executorService에서 실행하는 쿼리에서 Store를 찾을 수 없던것이다.
그렇다면 어떻게해야할까?
간단하게, Test Code에서 @Transactional을 빼면 된다.
왜일까? save메서드 호출하면 commit을 하니까, DB자체에 Store1이 올라가게 된다.
그러면, executorService에서 실행하는 increas()메서드에서 findBy메서드 호출시 DB에다가 select쿼리를 날려서 Store1을 가져오면된다.
결론
멀티스레드에서는 트랜잭션과 1차 캐시에 대한 데이터 일관성이 깨지면 안된다.
각 스레드마다 다른 엔티티매니저를 가지므로, 서로 다른 1차캐시를 가진다.
따라서, 다른 스레드에서 영속된 에티티를 가져다 쓰려면 해당 스레드의 트랜잭션이 정상적으로 완료 되어야 사용 할 수 있다.
고찰.
결국 해당 문제를 해결하기 위해서는 Test Code에서 @Transactional을 빼면 된다.
그러나, 해당 결과를 얻기 위해서
JPA 영속성 컨텍스트에 대한 공부
트랜잭션과 Test Code에서 유의해야하는 트랜잭션 애노테이션 공부
트랜잭션 전파 공부
스레드간의 영속성 컨텍스트까지 공부
이러한 긴 과정을 거쳤다.
동시성제어는 서비스에서 필수적이고 중요하다. 좋은 경험이었던 것 같다.