비동기를 활용하는 상황
- 동기방식으로 처리를 해야하는 작업이 아니라면 비동기 방식을 고민해보는 것도 좋지 않을까?
- 동기방식이 맞는 경우
- 결제 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배 증가함을 알 수 있다.
- 평균 테스트 시간이 크게 단축되고, 테스트 수행 수도 증가함에 따라 동기 방식보다 비동기 방식을 활용 할 때 성능이 향상됨을 알 수 있다.
- 평균 TPS : { 0.2 } → { 32.4 }
'개발 > 성능개선' 카테고리의 다른 글
인덱스를 타지 않는 경우 (0) | 2024.03.09 |
---|---|
Mysql Profiling으로 수치 확인 (0) | 2024.03.09 |
인덱스 활용 (0) | 2024.03.08 |
Ehcache (0) | 2024.03.08 |
nGrinder 용어 (0) | 2024.03.05 |