프로젝트/리팩토링

테스팅 1. 멀티 쓰레드 환경

임지혁코딩 2024. 5. 3. 20:30

멀티 쓰레드란?

 

멀티 쓰레드란 무엇인지, 먼저 간단하게 정리하고 넘어가자. 

 

실행 중인 프로그램의 단위를 프로세스, 프로세스의 작업 단위를 쓰레드라 함을 모두가 알고 있을 것이다.

멀티 쓰레드 라는 것은, 하나의 프로세스 단위에서 여러가지의 쓰레드를 사용하는 것을 의미한다.

 

출처 : https://connie.tistory.com/12

 

이와 같은 방식으로, 이미 메모리 공간을 할당받아 동작중인 프로그램에서 여러 작업을 처리하는 것을 의미한다.

 

JAVA와 SPRING의 경우에서

 

JAVA에서는 기본적으로 , main이라는 한개의 쓰레드가 동작한다.

데몬 쓰레드라는 것도 존재하는데, 이는 간단하게 '주 스레드의 작업을 돕는 보조적인 스레드' 를 의미한다.

메인 쓰레드가 종료되면 같이 종료되는데, 이로 인해서 가비지 컬렉션 등 생명주기와 관련있는 작업을 하기 유리하다.

메인보다 주요도가 낮은 작업들을, 처리할때 유리하다.

 

왜 이번 프로젝트에서 멀티 쓰레드 환경을 테스트 해야 할까?

 

가장 먼저, SPRING의 경우에는 쓰레드 풀(쓰레드를 미리 만들어둔 모음)을 활용하여 쓰레드를 사용하고 관리한다.

이는 TOMCAT SERVER가 관리하게 된다.

즉, spring framework는 기본적으로 멀티 쓰레드 환경을 사용한다! 

 

명확한 테스트 케이스를 제시하여 보겠다.

 

EX) 사용자 100명이, 동시에 결제를 진행했다. 

그 중 가장 처음 쓰레드에서 작업한, 결제 완료만 반영되고

나머지 99개의 결제는 거절(DENIED)되고 해당 정보가 저장 되어야 한다. 

 

잘 이루어질까?  확인하여보자

 

테스트 진행

 

1. CountDownLatch란?

쓰레드의 작업 종료를 기다리기 위한 것. 

Sleep 혹은 다른 방식으로 100개의 쓰레드가 언제 종료되는지를 확인한다고 하자.

하나의 쓰레드 종료 이후 sleep을 해버리면, 이는 대기 상태이기 때문에 DEADLOCK의 위험이있다.

즉, 언제 쓰레드가 끝났는지를 명확하게 알려준다!

CountDownLatch latch = new CountDownLatch(totalRequests);

 

2. TaskExecutor

일정 수만큼 쓰레드를 확보하고, 그 쓰레드들이 일정 작업을 처리하게 도와주는 테스트용 인터페이스 

@Autowired
private ThreadPoolTaskExecutor taskExecutor;
@Autowired
private PaymentService paymentService; 

//동시성 발생 상황 -> 결제 창에서는 여러명이 결제가 가능하지만, 해당 상품이 동시 구매임을 확인하는 순간은
//MEMBER CONTAINER에 요청했을 상황! -> "이미 결제가 완료된 상품입니다" 인 경우.
@Test
void multiThreadTesting() {

    when(paymentService.sendPaymentSuccessRequestToMember(anyString(), anyString(), any(ValidationRequest.class), anyString()))
            .thenReturn(true) //첫번째 결과-> 지현이형이주는 값을 성공으로 취급
            .thenReturn(false); // 이후 결과들을  false로 취급. -> "이미구매된상품입니다"

    int totalRequests = 100;
    AtomicInteger successCount = new AtomicInteger(0);
    AtomicInteger failureCount = new AtomicInteger(0);

    //할 일들 모으기
    CountDownLatch latch = new CountDownLatch(totalRequests);

    for (int i = 0; i < totalRequests; i++) {
        taskExecutor.execute(() -> {
            boolean result = paymentService.sendPaymentSuccessRequestToMember("사과 제품", "2024-04-13", new ValidationRequest(), "jj1234@naver.com");
            if (result) {
                successCount.incrementAndGet();
            } else {
                failureCount.incrementAndGet();
            }
            latch.countDown();
        });
    }

 

완성한 코드 

@SpringBootTest
@Slf4j
class PaymentServiceTest {

    @Autowired
    private ThreadPoolTaskExecutor taskExecutor;
    @Autowired
    private PaymentService paymentService; 

    //동시성 발생 상황 -> 결제 창에서는 여러명이 결제가 가능하지만, 해당 상품이 동시 구매임을 확인하는 순간은
    //MEMBER CONTAINER에 요청했을 상황! -> "이미 결제가 완료된 상품입니다" 인 경우.
    @Test
    void multiThreadTesting() {

//        when(paymentService.sendPaymentSuccessRequestToMember(anyString(), anyString(), any(ValidationRequest.class), anyString()))
//                .thenReturn(true) //첫번째 결과-> 지현이형이주는 값을 성공으로 취급
//                .thenReturn(false); // 이후 결과들을  false로 취급. -> "이미구매된상품입니다"

        int totalRequests = 100;
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failureCount = new AtomicInteger(0);
        CountDownLatch latch = new CountDownLatch(totalRequests);

        ValidationRequest validationRequest = new ValidationRequest();

        // 필드 값 설정
        validationRequest.setPayment_id("payment-55bdc9a4-1b38-48fc-a812-0ee7014fac9e");
        validationRequest.setTotal_point(1000);
        validationRequest.setCreated_at(LocalDate.parse("2024-04-30"));

        // PaymentsReq 객체 생성 및 리스트에 추가
        PaymentsReq paymentReq = new PaymentsReq();
        paymentReq.setSeller("jj1234@naver.com");
        paymentReq.setConsumer("dealon25@naver.com");
        paymentReq.setProduct_point(100);
        paymentReq.setProduct_id(1L);
        paymentReq.setPurchase_at(LocalDate.parse("2024-04-30"));
        List<PaymentsReq> paymentsList = Arrays.asList(paymentReq);

        validationRequest.setPayments_list(paymentsList);


        for (int i = 0; i < totalRequests; i++) {
            taskExecutor.execute(() -> {
                Mono<PaymentsRes> result = paymentService.sendPaymentSuccessRequestToMember("사과 제품", "2024-04-13", validationRequest, "jj1234@naver.com");
                if (Objects.requireNonNull(result.block()).getMessage().equals("구매하려는 상품중 판매된 상품이 있습니다.") ) {
                    failureCount.incrementAndGet();
                } else {
                    successCount.incrementAndGet();
                }
                latch.countDown();
            });
        }


        //then
        //1개만 통과! 나머지는 다른결과(false로 이미 지정)

        assertEquals(totalRequests - 1, failureCount.get());
        assertEquals(1, successCount.get());
    }

}

 

하지만.. 테스트가 실패했다. 

 

 

99개의 실패 1개의 성공이 나와야하는데.. 둘다 0개가 나온다?

 

아차. 내 영역이 아닌 SERVICE의 문제로 문제가 발생하면 EXCEPTION을 하기로 했었다.

 

다시 구성하였고, 또 다시.. 문제가 발생했다.

 

1개 결제는 잘 되었다.

 

 

테스트도 통과.. 그렇다면 xlock을 기다린 이후 결제 취소도 안되었을까?

 

 

결제 취소는 잘 되었다! 그렇지만 예측 결과가 안나왔다면.. 이건 코드상의 문제라고 판단했다.  코드를 다시보자. 

 

 

테스트 코드를, 결제가 잘 완료되면 paymentres를, 그렇지 않다면 error를 담는 method의 특징을 활용하여

응답 타입을 보고 확인하는 형태로 변경하였다.

 

근데, FOR문 안에 있는데 동시에 10개의 쓰레드가 동작한걸 TEST한게 맞아?

해당 내용이 갑자기 불안하여, execute method를 다시 확인해 보았다. 

 

 

해당 METHOD가 쓰레드 풀들이 비동기적으로 작업하고, 그 와중에 발생하는 충돌 까지 고려한 테스트라면,

한가지의 필요충분조건이 있다. 그것은 바로 

excute method가 return을 받지 않고 , 바로 다음 쓰레드를 동작시켜야 한다는 것! 

공식 문서를 찾아보자. 

 

All tasks returned from TAP methods must be “hot.” If a TAP method internally uses a Task’s constructor to instantiate the task to be returned, the TAP method must call Start on the Task object prior to returning it. Consumers of a TAP method may safely assume that the returned task is “hot,” and should not attempt to call Start on any Task returned from a TAP method. Calling Start on a “hot” task will result in an InvalidOperationException (this check is handled automatically by the Task class).

 

- STACKOVERFLOX, SPRING 공식 문서.

즉, 바로 쓰레드의 동작 후 RETURN을 받지 않고 다음 쓰레드를 동작시킨다! 

 

이제야 동시성 TEST를 완료하였다.