본문 바로가기
개발/성능개선

비동기 방식

by BellOne4222 2024. 3. 10.

비동기를 활용하는 상황

  • 동기방식으로 처리를 해야하는 작업이 아니라면 비동기 방식을 고민해보는 것도 좋지 않을까?
    • 동기방식이 맞는 경우
      • 결제 API 에서 pg사로 결제 요청을 한 경우 pg사에서 정상적으로 결제처리가 완료되었다는 응답을 받고 추가 작업을 진행하는 경우
      • 사전 작업 완료가 된 후 이어서 다음 작업을 할 수 있는 경우
    • 비동기방식을 활용해도 되는 경우
      • 100명에 사용자에게 쿠폰 발송을 한다면 1번 사용자 발송이 완료되고 2번 사용자를 발송하고 이런 발송 처리

 

java completeFuture 를 활용한 비동기처리

  • 캐싱과 인덱싱에 사용했던 공지사항 테이블을 사용
  • 상황 : 공지사항 전체 데이터를 조회하고 그 만큼을 로그 발송 처리
  • 발송을 → log 로 대체
  • 발송처리에 드는 작업 시간을 → Thread.sleep(5); 로 대체

 

동기 방식으로 처리

@Override
    public long sendAll() {
        List<Notice> notices = noticeService.getAllNotices();
        long beforeTime = System.currentTimeMillis();

        /* 동기 방식 */
        notices.forEach(notice ->
                sendLog(notice.getTitle())
        );

        long afterTime = System.currentTimeMillis();
        long diffTime = afterTime - beforeTime;
        log.info("실행 시간(ms): " + diffTime);
        return diffTime;
    }

    public void sendLog(String message) {
        try {
            Thread.sleep(5); // 임의의 작업시간을 주기위해 설정
            log.info("message : {}", message);
        }catch (Exception e) {
            log.error("[Error] : {} ",e.getMessage());
        }
    }

 

  • 결과

 

  • 실행 시간 : 7368 ms

 

비동기 방식

public long sendAll() {
    List<Notice> notices = noticeService.getAllNotices(); // 5000건
    long beforeTime = System.currentTimeMillis();

	  /* 비동기 방식 */
    notices.forEach(notice ->
            CompletableFuture.runAsync(() -> sendLog(notice.getTitle()))
                    .exceptionally(throwable -> {
												// 개발자 담당자한테 web hook 및 전달할 있게 처리하기.
                        log.error("Exception occurred: " + throwable.getMessage());
                        return null;
                    })
    );

    long afterTime = System.currentTimeMillis();
    long diffTime = afterTime - beforeTime;
    log.info("실행 시간(ms): " + diffTime);
    return diffTime;
}


public void sendLog(String message) {
    try {
        Thread.sleep(5); // 발송처리 시간이라고 가정하고 처리
        log.info("message : {}", message);
    }catch (Exception e) {
        log.error("[Error] : {} ",e.getMessage());
    }
}
  • sendlog 호출을 했을 때 응답을 기다리는게 아니라 호출을 계속 해주기 때문에 응답이 빠르다.
  • 후속 작업이 필요없기 때문에 꼭 응답을 기다릴 필요가 없다.
  • 위에서 가정한 상황은 비동기 방식을 적용하기 좋은 케이스
  • 비동기 방식은 응답을 기다리지 않고 바로 호출만 하기 때문에 순서를 보장하지않는다.
    • 비동기 처리가 실패한 경우 감지할 수 있게 예외처리를 해줘야 한다.
.exceptionally(throwable -> {
							// 어떤 발송처리가 실패하였는지
							// 개발/기획 담당자한테 web hook 및 전달할 있게 추가 처리가 필요.
              log.error("Exception occurred: " + throwable.getMessage());
              return null;
          })

 

  • 결과

  • 실행 시간 : 8ms
  • 동기 방식에 비해 921배 빠른 실행시간이라는 성능 향상과 결과를 볼 수 있다.

 

비동기 방식 사용시 주의사항

  • 사전 작업에 완료를 기다려하는 작업에는 동기방식을 활용해야한다.
    • ex) 결제 API에서 PG사/간편결제(카카오페이,네이버페이등..)에 결제 요청을 하는 경우에는 요청에 응답을 받은 후 후속처리를 해야하기 때문에 동기방식이 적절
  • thread pool 설정
    • thread pool 설정을 하지 않는다면 common pool 을 사용하게 되는데 사용하지 않도록 별도의 thread pool에 thread를 사용하도록 설정

 

runAsync 메서드를 타고 확인

  • runAsync 안에 threadpool 설정 할 수 있다.
/**
     * Returns a new CompletableFuture that is asynchronously completed
     * by a task running in the given executor after it runs the given
     * action.
     *
     * @param runnable the action to run before completing the
     * returned CompletableFuture
     * @param executor the executor to use for asynchronous execution
     * @return the new CompletableFuture
     */
    public static CompletableFuture<Void> runAsync(Runnable runnable,
                                                   Executor executor) {
        return asyncRunStage(screenExecutor(executor), runnable);
    }
    private static final int THREAD_POOL_SIZE = 10;

	private final ExecutorService customThreadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);

	notices.forEach(notice ->
                CompletableFuture.runAsync(() -> sendLog(notice.getTitle()),customThreadPool)
                        .exceptionally(throwable -> {
                            log.error("Exception occurred: " + throwable.getMessage());
                            return null;
                        })
        );

 

int threadPoolSize = Math.max(2, processors); // 최소한 2개의 스레드는 사용
ExecutorService customThreadPool = Executors.newWorkStealingPool(threadPoolSize); 

/* 비동기 방식 */ // 바로 다음 작업을 호출
        notices.forEach(notice -> // 비동기 방식
                CompletableFuture.runAsync(() -> sendLog(notice.getTitle()), customThreadPool) // customThreadPool 사용
                        .exceptionally(throwable -> { // 예외 처리
                            log.error("Exception occurred: " + throwable.getMessage()); // 에러 로그 출력
                            // 이슈 발생을 담당자가 인지 할수 있도록 추가적인 코드가 필요
                            return null;
                        })
        );

common pool을 사용했을 때 (runAsync에서 customThreadPool을 지정 안하고 한 경우)

  • onPool-worker

 

thread pool을 사용 했을 때 (runAsync에서 customThreadPool을 지정 한 경우)

  • pool-1-thread

 

비동기 방식 활용 후 성능 테스트

  • 상황 : 1천건의 데이터를 조회하고 1천건의 데이터를 발송하는 작업에 대해 비교
    • 데이터 발송은 sendLog로 대체
public void sendLog(String message) {
        try {
            Thread.sleep(1); // (1 ms) 각 작업에 소요시간을 Thread.sleep(1)로 대체 
            log.info("message : {}", message);
        }catch (Exception e) {
            log.error("[Error] : {} ",e.getMessage());
        }
    }

 

응답시간에 대한 동기 방식과 비동기 방식의 차이

  • Tool : Postman
  • GET : localhost:8080/api/send/logs

  • 동기 방식
    • 결과 : 8.12 s = 8120000ms

 

  • 비동기 방식
    • 결과 : 721ms

 

  • 비교 결과
    • 비동기 방식이 동기 방식에 비해 약 11262배 향상된 실행 시간을 얻을 수 있었다.

 

nGrinder 를 사용한 부하테스트

  • 테스트 script : logsend.groovy
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "127.0.0.1")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		HTTPResponse response = request.GET("https://127.0.0.1:8080/api/send/logs", params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}

 

  • 비동기 방식 사용전
    • Vuser(가상 사용자) : 2
    • Duration : 1분
  • 결과

 

  • 비동기 방식 사용시
    • Vuser(가상 사용자) : 2
    • Duration : 1분

  • 수치 변화
    • 평균 TPS : { 0.2 } → { 32.4 }
      • TPS가 높아진 이유는 1분안에 많은 데이터 처리를 함에 따라 API 자체가 무거워 짐에 따라 증가
    • Peek TPS : { 1.0 } → { 105.5 }
    • Mean Test Time : { 7313 } ms → { 57.03 }ms
      • 평균 테스트 시간은 약 128배 단축
    • Exected Tests : { 8 } → { 1813 }
      • 테스트 수행 수도 약 227배 증가함을 알 수 있다.
    • 평균 테스트 시간이 크게 단축되고, 테스트 수행 수도 증가함에 따라 동기 방식보다 비동기 방식을 활용 할 때 성능이 향상됨을 알 수 있다.

'개발 > 성능개선' 카테고리의 다른 글

인덱스를 타지 않는 경우  (0) 2024.03.09
Mysql Profiling으로 수치 확인  (0) 2024.03.09
인덱스 활용  (0) 2024.03.08
Ehcache  (0) 2024.03.08
nGrinder 용어  (0) 2024.03.05