이번 게시글에서는 챗봇 서비스를 개발 중에 발생한 동시성 문제를 테스트하기 위해 작성한 테스트 코드에서 발생한 문제를 기술하고자 한다.
챗봇 서비스를 개발하는 프로젝트에서 사용자가 네이버 웹툰 쿠키와 같은 배터리를 구매하고, 이를 이용하여 챗봇을 구매하는 기능을 구현하고 있다. 여기서 사용자가 챗봇 구매 요청을 동시에 보낼 경우 동시성 문제가 발생할 것으로 예상하여 테스트 코드를 작성하였다. 이를 위해 ExecutorService를 이용하여 멀티스레드를 사용하여 서비스의 로직을 비동기적으로 거의 동시에 호출하는 방법으로 테스트 코드를 작성하였다.
그러나 테스트 결과는 사진에서 볼 수 있듯이, "The specified member could not be found." 라며 서비스 로직에서 사용자를 찾지 못하는 문제가 발생하였다.
테스트 로직은 아래와 같다.
@SpringBootTest
@Transactional
@ActiveProfiles("test")
public class ChatBotServiceConcurrencyTest {
@Autowired
private ChatBotService chatBotService;
@Autowired
private MemberRepository memberRepository;
private CustomOAuth2User customOAuth2User;
@BeforeEach
void setUp() {
MemberEntity memberEntity = MemberEntity.builder()
.name("testUser")
.oAuth2(OAuth2Type.OAUTH2_GITHUB)
.username("testUser")
.profileImage("testProfileImage")
.role(RoleType.ROLE_USER)
.build();
memberEntity.addBatteries(100);
MemberEntity savedMemberEntity = memberRepository.save(memberEntity);
customOAuth2User = new CustomOAuth2User(savedMemberEntity.getId(), savedMemberEntity.getRole(),
savedMemberEntity.getOAuth2());
}
@Test
void testConcurrentPurchaseRequests() throws InterruptedException {
int numberOfThreads = 3;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
ChatBotPurchaseRequest purchaseRequest1 = new ChatBotPurchaseRequest("아저씨 챗봇");
ChatBotPurchaseRequest purchaseRequest2 = new ChatBotPurchaseRequest("아줌마 챗봇");
ChatBotPurchaseRequest purchaseRequest3 = new ChatBotPurchaseRequest("어린이 챗봇");
ChatBotPurchaseRequest[] purchaseRequests = {purchaseRequest1, purchaseRequest2, purchaseRequest3};
for (ChatBotPurchaseRequest purchaseRequest : purchaseRequests) {
executorService.submit(() -> {
try {
chatBotService.purchaseChatBot(purchaseRequest, customOAuth2User);
} catch (Exception e) {
System.out.println(e.getMessage());
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);
MemberEntity updatedMemberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(() -> new IllegalStateException("Member not found"));
System.out.println("Remaining batteries: " + updatedMemberEntity.getBatteryCount());
System.out.println("Purchased chatbots: " + updatedMemberEntity.getChatBots());
}
}
@SpringBootTest, @Transactional, @ActiveProfiles("test") 어노테이션을 통해 테스트 환경을 설정하고, ExecutorService를 이용하여 3개의 스레드로 동시에 챗봇 구매 요청을 보내는 테스트를 수행한다.
문제점을 파악하기 전에 먼저 순차적으로 요청을 보냈을 때도 문제가 발생하는지 확인하기 위해 순차적인 요청도 테스트해 보았다. 코드는 아래와 같다.
@Test
void testSequentialPurchaseRequests() {
ChatBotPurchaseRequest purchaseRequest1 = new ChatBotPurchaseRequest("아저씨 챗봇");
ChatBotPurchaseRequest purchaseRequest2 = new ChatBotPurchaseRequest("아줌마 챗봇");
ChatBotPurchaseRequest purchaseRequest3 = new ChatBotPurchaseRequest("어린이 챗봇");
try {
chatBotService.purchaseChatBot(purchaseRequest1, customOAuth2User);
chatBotService.purchaseChatBot(purchaseRequest2, customOAuth2User);
chatBotService.purchaseChatBot(purchaseRequest3, customOAuth2User);
} catch (Exception e) {
System.out.println(e.getMessage());
}
MemberEntity updatedMemberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(() -> new IllegalStateException("Member not found"));
System.out.println("Remaining batteries: " + updatedMemberEntity.getBatteryCount());
System.out.println("Purchased chatbots: " + updatedMemberEntity.getChatBots());
}
챗봇 구매 요청을 순차적으로 수행하여, 서비스 로직이 올바르게 동작하는지 확인한다.
결과는 아래와 같다.
예상했던 결과처럼 배터리가 적절하게 소모되었고, 보유한 챗봇도 적절하게 증가하였다.
문제를 해결하기 위해 디버깅을 시도해 보았다. ExecutorService에서 실행한 스레드가 챗봇을 구매하는 서비스 로직에 접근할 때를 기준으로 사용자의 정보를 받아올 수 있는지 확인해 보았다. 결과는 아래와 같다.
memberRepository.findAll()을 실행했으나, 어떠한 사용자도 불러오지 못하였다. 여기서 테스트 코드에서 롤백을 위한 @Transactional과 스레드의 @Transactional이 다르다는 것을 알 수 있었다.
테스트 코드와 @Transactional이 다르기 때문에 1차 캐시도 다를 것이며, 테스트 코드에서 저장했던 사용자 정보에 접근이 불가능하였다. 이를 해결하기 위해서는 어떻게 해야 할까?
첫 번째로 시도한 방법은 @Transactional의 전파 속성을 다르게 설정하여 테스트 코드에서 작성한 사용자 정보를 저장하는 코드를 커밋한 뒤, 테스트를 실행하는 것이었다. 참고로 @Transactional은 프록시 방식의 AOP 방식으로 동작하기 때문에 같은 클래스 내에서는 @Transactional의 전파 속성을 다르게 해도 적용되지 않는다. 따라서 다른 클래스에서 사용자를 저장하는 로직을 구현해야 한다.
하지만 이 방식도 문제점이 있다. 테스트를 수행한 뒤, 테스트를 위한 사용자 정보가 롤백되지 않고 다음 테스트에 영향을 미치게 된다. 테스트 롤백을 위한 @Transactional을 사용하였으나, 해당 기능이 작동하지 않는 것이다. 이러한 문제 때문에 필자는 테스트 롤백을 위한 @Transactional 사용을 포기하였다.
동시성 문제를 점검하기 위한 테스트 로직이기 때문에 해당 테스트만 명시적으로 데이터베이스를 초기화하는 방법은 오버헤드가 크지 않을 것으로 판단하여 해당 방식을 선택하였다. 코드는 아래와 같다.
package org.jungppo.bambooforest;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Table;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile(value = "test")
public class DatabaseCleaner implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
.map(e -> {
Table table = e.getJavaType().getAnnotation(Table.class);
return table != null ? table.name() : toSnakeCase(e.getName());
})
.collect(Collectors.toList());
}
@Transactional
public void clean() {
entityManager.flush();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
private String toSnakeCase(String str) {
return str.replaceAll("([a-z])([A-Z]+)", "$1_$2").toLowerCase();
}
}
DatabaseCleaner 클래스는 데이터베이스를 초기화하는 기능을 제공하며, 테스트 프로파일에서만 활성화된다. clean 메서드는 모든 테이블의 데이터를 삭제하여 테스트 환경을 초기화한다.
해당 기능을 사용하는 코드는 아래와 같다.
@SpringBootTest
@ActiveProfiles("test")
public class ChatBotServiceConcurrencyTest {
@Autowired
private ChatBotService chatBotService;
@Autowired
private MemberRepository memberRepository;
/**
* 테스트에서 @Transactional과 ExecutorService가 생성한 스레드의 Transactional이 다름. ExecutorService에서 초기 데이터를 조회하지 못함.
* 테스트에서 @Transactional를 사용하지 않고 명시적으로 데이터베이스 초기화.
*/
@Autowired
private DatabaseCleaner databaseCleaner;
private CustomOAuth2User customOAuth2User;
@BeforeEach
void setUp() {
databaseCleaner.clean();
MemberEntity memberEntity = MemberEntity.builder()
.name("testUser")
.oAuth2(OAuth2Type.OAUTH2_GITHUB)
.username("testUser")
.profileImage("testProfileImage")
.role(RoleType.ROLE_USER)
.build();
memberEntity.addBatteries(100);
MemberEntity savedMemberEntity = memberRepository.save(memberEntity);
customOAuth2User = new CustomOAuth2User(savedMemberEntity.getId(), savedMemberEntity.getRole(),
savedMemberEntity.getOAuth2());
}
}
DatabaseCleaner를 이용하여 각 테스트 시작 전에 데이터베이스를 초기화하고, 테스트에 필요한 초기 데이터를 설정한다.
수행 결과는 아래와 같다.
병렬적으로 요청한 테스트와 순차적으로 요청한 테스트 모두 성공하였고, 병렬적으로 요청한 테스트도 실행 결과를 잘 보여주는 모습이다.