0. 들어가기 전
프로젝트를 진행하다가 멀티 스레드를 통해 동시 요청을 테스트하는 도중에, 초기 데이터를 설정하는 부분에서 문제가 발생했습니다.
문제 상황과 원인, 해결 방법을 알아보도록 하겠습니다.
1. 문제 상황
프로젝트에서 사용자의 중복 요청을 방지하는 로직을 구현해야 했습니다.
그래서 중복 요청을 테스트하기 위해 멀티 스레드 테스트를 다음과 같이 작성했습니다.
@Test
@DisplayName("1명의 사용자가 동선 생성 요청을 여러번 보내는 경우 1번만 성공하고 예외가 발생한다.")
@Transactional
void throws_multiple_request() throws InterruptedException {
...
final ExecutorService executorService = Executors.newFixedThreadPool(30);
final int requestCount = 3;
final CountDownLatch countDownLatch = new CountDownLatch(requestCount);
final Member savedMember = memberRepository.save(MemberFixtures.기본_회원_엔티티());
for (int i = 0; i < requestCount; i++) {
executorService.execute(() -> {
try {
routeService.addRoute(savedMember.getId(), routeAddRequest);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
...
}
- 1명의 사용자가 3번의 중복 요청을 보내는 상황 테스트
- CountDownLatch를 통해 멀티 스레드 작업 대기 처리
- ExecutorService를 통해 멀티 스레드로 작업들을 처리하여 동시에 처리되는 상황을 구현
여기서, 중복 요청에 Member의 ID가 사용되므로 멀티 스레드 작업 전에 memberRepository.save로 멤버를 저장했습니다.
이렇게 테스트를 실행하면 다음과 같은 에러로 테스트가 실패합니다.
해당 예외가 실행하는 3개 스레드별로 모두 발생합니다.
2. 문제 원인
해당 문제의 원인은 무엇일까요?
이는 트랜잭션의 격리수준과 관련이 있습니다.
결론적으로 원인을 살펴보면, 멀티 스레드 작업의 트랜잭션에서 앞서 시작한 테스트 스레드의 트랜잭션 작업을 읽지 못하는 것이 원인입니다.
저는 프로덕션 환경의 RDB를 MySQL로 사용했기 때문에 테스트 환경에서도 MySQL로 테스트를 진행하였습니다.
MySQL InnoDB의 기본 격리 수준은 Repeatable Read입니다.
Repeatable Read의 특징은 다음과 같습니다.
트랜잭션이 시작되기 전에 커밋된 내용에 대해서만 조회할 수 있는 격리수준
(자세한 격리수준의 내용은 이전에 포스팅한 제 글을 첨부하도록 하고 넘어가도록 하겠습니다.)
https://ksh-coding.tistory.com/118#3-3.%20REPEATABLE%20READ-1
이렇게 기본 격리 수준이 Repeatable Read인 상황에서, 테스트의 트랜잭션 상황을 살펴봅시다.
현재 테스트 상황을 트랜잭션별 작업 중심으로 살펴보면 다음과 같습니다.
- MemberRepository.save : 기본 테스트 스레드에서 트랜잭션 시작 후 테스트 종료 시에 트랜잭션 종료
- 멀티 스레드 작업 : Exectuor가 할당한 스레드에서 트랜잭션 시작 후 작업 후 트랜잭션 종료
멀티 스레드 작업 트랜잭션이 시작하기 전에, 테스트 스레드의 트랜잭션의 멤버 저장 명령은 테스트가 종료되지 않았기 때문에
완료되지 않은 상태입니다.
따라서 멀티 스레드 작업 트랜잭션이 시작할 때 격리 수준이 Repeatable Read이므로
커밋되지 않은 멤버 저장 명령의 결과를 읽지 못합니다.
그래서 멀티 스레드 작업 트랜잭션에서 멤버를 찾을 수 없다고 예외가 발생한 것입니다.
이렇게 멀티 스레드 작업을 처리할 때, 격리 수준에 의해 커밋되지 않은 Member 데이터를 읽지 못하기 때문에 발생하는 문제였습니다.
3. 해결 방법
위의 문제의 해결 방법으로는 크게 3가지 방법을 생각해봤습니다.
- 격리 수준을 조절해서 멀티 스레드 작업 트랜잭션에서 테스트 트랜잭션의 멤버 저장 읽어오기
- 멀티 스레드 작업 트랜잭션 시작 전에 테스트 트랜잭션을 커밋하기
- 멤버 저장 작업을 별도의 트랜잭션(스레드)으로 처리하여 커밋하기
3-1. 격리 수준을 조절해서 멀티 스레드 작업 트랜잭션에서 테스트 트랜잭션의 멤버 저장 읽어오기
이 방법은 문제의 원인인 격리 수준을 조절해서 멤버 데이터를 읽어오는 방법입니다.
테스트 시 격리 수준이 Repeatable Read였기 때문에 발생하는 문제이므로
이전 트랜잭션이 커밋되지 않아도 작업만 수행되었으면 데이터를 읽어올 수 있는 Read Uncommitted를 사용하면 됩니다.
이때, 테스트의 @Transactional이 아닌 프로덕션 코드의 @Transactional 격리 수준을 변경해야 합니다.
멤버 데이터를 읽어서 사용하는 로직이 프로덕션 코드이므로 프로덕션 코드의 @Transactional 격리 수준을 변경해야 합니다.
따라서, RouteService의 addRoute의 격리 수준을 다음과 같이 변경하면 됩니다.
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public Long addRoute(...) {
...
}
이렇게 설정 후 테스트를 시도하면 멀티 스레드 작업에서 멤버를 읽어오기 때문에 멤버 예외 없이 테스트가 성공합니다.
하지만 이 방법은 다음과 같은 단점때문에 사용하지 않았습니다.
- 격리 수준으로 사용하는 READ_UNCOMMITTED 자체가 서비스에서 사용하기 힘듭니다.
- 아무리 데이터 정합성이 중요하지 않은 서비스라도 동시 요청 시에 데이터 정합성이 무너지고, 추적이 힘들어지기 때문에 일반적으로 사용하기가 힘듭니다.
- 테스트를 위한 프로덕션 코드의 변경은 지양해야 합니다.
- 프로덕션 코드의 테스트를 위해 테스트를 짜는 것인데, 주객전도가 되면 안되기 때문입니다.
- 테스트를 위한 getter 추가, 생성자 추가 등 프로덕션에 거의 영향을 미치지 않는 코드 변경은 괜찮다고 생각하지만, 격리수준은 중요한 영향을 미치기 때문에 좋지 않다고 생각합니다.
3-2. 멀티 스레드 작업 트랜잭션 시작 전에 테스트 트랜잭션을 커밋하기
이 방법은 격리 수준 조절없이 트랜잭션 시작 전의 커밋된 내용만 조회할 수 있는 Repeatable Read 격리 수준에 맞추는 방법입니다.
그래서 멀티 스레드의 트랜잭션을 시작하기 전에, 멤버 저장 명령을 커밋하여 멀티 스레드 트랜잭션에서 읽을 수 있도록 합니다.
해당 방법도 생각해봤을 때 크게 2가지 방법으로 생각해봤습니다.
- 테스트의 @Transactional을 삭제해서 개별 명령이 즉시 커밋되도록 구현
- 테스트의 트랜잭션을 관리하는 TestTransaction 객체로 즉시 커밋 후 완료되도록 구현
1. 테스트의 @Transactional을 삭제해서 개별 명령이 즉시 커밋되도록 구현
@Test
@DisplayName("1명의 사용자가 동선 생성 요청을 여러번 보내는 경우 1번만 성공하고 예외가 발생한다.")
// @Transactional (트랜잭셔널 어노테이션 제거)
void throws_multiple_request() throws InterruptedException {
...
final ExecutorService executorService = Executors.newFixedThreadPool(30);
final int requestCount = 3;
final CountDownLatch countDownLatch = new CountDownLatch(requestCount);
final Member savedMember = memberRepository.save(MemberFixtures.기본_회원_엔티티());
for (int i = 0; i < requestCount; i++) {
executorService.execute(() -> {
try {
routeService.addRoute(savedMember.getId(), routeAddRequest);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
...
}
간단히 @Transactional 어노테이션을 제거하면 개별 명령이 즉시 커밋되고 완료되기 때문에 테스트가 성공하게 됩니다.
2. 테스트의 트랜잭션을 관리하는 TestTransaction 객체로 즉시 커밋 후 완료되도록 구현
@Test
@DisplayName("1명의 사용자가 동선 생성 요청을 여러번 보내는 경우 1번만 성공하고 예외가 발생한다.")
@Transactional
void throws_multiple_request() throws InterruptedException {
...
final ExecutorService executorService = Executors.newFixedThreadPool(30);
final int requestCount = 3;
final CountDownLatch countDownLatch = new CountDownLatch(requestCount);
final Member savedMember = memberRepository.save(MemberFixtures.기본_회원_엔티티());
// TestTransaction을 사용하여 테스트 트랜잭션 커밋, 완료 수행
TestTransaction.flagForCommit();
TestTransaction.end();
for (int i = 0; i < requestCount; i++) {
executorService.execute(() -> {
try {
routeService.addRoute(savedMember.getId(), routeAddRequest);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
...
}
TestTransaction 객체는 테스트 환경 트랜잭션의 정적 메소드를 통한 관리를 제공합니다.
그래서 멀티 스레드 작업 전에 flagForCommit(), end()를 통해 멤버 저장 명령을 커밋하였습니다.
이렇게 설정 후 테스트를 시도하면 테스트가 성공하게 됩니다.
이렇게 명령 수행 후에 바로 트랜잭션을 커밋하게 하는 방법 2가지를 구현해봤습니다.
하지만 이 방법 역시 다음과 같은 이유로 사용하지 않았습니다.
- @Transactional을 제거하는 방법은 테스트의 일관성을 깨는 느낌이 들었습니다.
- 다른 모든 테스트에는 @Transactional을 적용하고 있기 때문에, 일관성이 깨지는 느낌이 들었습니다.
- @Transactional을 사용하지 않을 때 Side-Effect가 존재합니다.
- JPA 사용 시 LAZY 로딩을 많이 사용하기 때문에 객체를 실제 객체가 아닌 프록시 객체로 생성하고, 사용 시에 실제 데이터를 주입하여 실제 객체를 사용하게 됩니다.
- @Transactional을 사용하지 않으면 명령이 개별로 커밋되고 그 즉시 트랜잭션이 종료되기 때문에, 위의 테스트 상황에서 Member 객체를 이후에 불러올 때는 트랜잭션이 닫혀있기 때문에 LazyInitializationException가 발생합니다.
- TestTransaction을 사용하는 방법은 자연스러운 프로덕션 코드의 테스트가 아닌, 강제로 테스트 환경에서 제어하는 느낌이 들었습니다.
- 프로덕션 코드를 테스트하기 위한 코드인데, 테스트 환경에서 강제로 트랜잭션을 관리한다는 것이 좋지 않아 보였습니다.
3-3. 멤버 저장 작업을 별도의 트랜잭션(스레드)으로 처리하여 커밋하기
결과적으로, 마지막 방법인 이 방법을 적용하여 테스트를 마무리하게 되었습니다.
이 방법은 ExecutorService를 통해 별도의 스레드를 생성해서 멤버 저장 작업을 처리하는 방법입니다.
스레드별로 트랜잭션이 생성되므로 별도의 트랜잭션이 생성되고 해당 작업이 종료될 때 트랜잭션이 완료되므로 해결할 수 있습니다.
@Test
@DisplayName("1명의 사용자가 동선 생성 요청을 여러번 보내는 경우 1번만 성공하고 예외가 발생한다.")
@Transactional
void throws_multiple_request() throws InterruptedException {
...
final ExecutorService executorService = Executors.newFixedThreadPool(30);
final int requestCount = 3;
final CountDownLatch countDownLatch = new CountDownLatch(requestCount);
// 별도의 스레드를 생성해서 멤버 저장 작업 처리
final Member savedMember = executorService.submit(() -> {
return memberRepository.save(MemberFixtures.기본_회원_엔티티());
}).get();
for (int i = 0; i < requestCount; i++) {
executorService.execute(() -> {
try {
routeService.addRoute(savedMember.getId(), routeAddRequest);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
...
}
이러한 방법을 사용했을 때, 이전 방법들의 단점을 보완한다고 생각했습니다.
- 프로덕션 코드 변경(격리 수준 변경)없이 테스트 구현 완료
- @Transactional을 그대로 테스트에서 사용하므로 테스트 일관성이 유지되고 Side-Effect X
- 인수테스트 느낌으로 멤버가 저장되는 로직도 자연스러운 테스트의 일부분으로 느껴짐
- 테스트를 처음 봤을 때 테스트 컨텍스트를 이해하기 더 쉽다고 느껴짐
이러한 생각이 들었기 때문에, 이 방법으로 해결하게 되었습니다.
이번에는 멀티 스레드 테스트에서 초기 데이터를 설정할 때 발생하는 오류를 트러블 슈팅해봤습니다.
일반적으로 멀티 스레드 테스트 레퍼런스를 찾아봤을 때 @Tranasctional을 제거하고 성공하는 경우를 많이 봤었는데,
해당 원리를 이번에 조금 알게되었고 여러 개념들을 이해해볼 수 있어서 좋았던 것 같습니다!
Reference
https://ksh-coding.tistory.com/118#3-3.%20REPEATABLE%20READ-1
'이슈 & 트러블 슈팅' 카테고리의 다른 글
[이슈] JPA N+1 문제 해결 및 성능 비교 (feat. Batch Size) (1) | 2024.07.11 |
---|---|
[이슈] Redis 캐싱을 통해 조회 성능 개선하기 (0) | 2024.07.01 |
[이슈 & 트러블 슈팅] 동시성 보장하기 (feat. 비관적 락, Redis 분산 락) (3) | 2024.06.11 |
[이슈] 사용자의 중복 요청 방지하기(feat. 멱등키, Redis) (1) | 2024.06.07 |
[트러블 슈팅] SpringBoot에서 application.yml 이외의 yml 프로퍼티 적용하기 (0) | 2022.08.26 |