새로운 내용을 공부할 때
새로운 내용의 공부를 시작할 때 용어의 정의를 이해하지 못하거나 정확하게 알지 못한다면 그 용어가 포함된 문장을 이해하지 못합니다.
작은 단어 하나가 내용을 이해하지 못하게 하기 때문에 용어를 정확하게 이해하는 것이 중요합니다.
TIL) 병목을 겪어보자
실제로 서버 부하를 걸어보면서 테스트를 진행해보려고 합니다.
서버 지연 테스트
동기 I/O 테스트
@RestController
public class SyncController {
public SyncController(SyncService syncService) {
this.syncService = syncService;
}
private final SyncService syncService;
@GetMapping("/sync/location")
public String getLocation() {
return syncService.blockingOperation();
}
}
@Slf4j
@Service
public class SyncService {
public String blockingOperation() {
try {
// 외부 API, DB 작업이 느린 상황을 가정
Thread.sleep(1000);
// locust log message
log.info("blockingOperation");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "동기 응답 완료";
}
}
간단하게 ab -n 100 -c 20
방식으로 테스트 진행
Server Software:
Server Hostname: localhost
Server Port: 8080
Document Path: /sync/location
Document Length: 20 bytes
Concurrency Level: 20
Time taken for tests: 11.136 seconds
Complete requests: 100
Failed requests: 0
Total transferred: 15300 bytes
HTML transferred: 2000 bytes
Requests per second: 8.98 [#/sec] (mean)
Time per request: 2227.135 [ms] (mean)
Time per request: 111.357 [ms] (mean, across all concurrent requests)
Transfer rate: 1.34 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1 0.4 1 2
Processing: 1009 1903 313.1 2011 2020
Waiting: 1009 1902 313.2 2011 2019
Total: 1010 1903 313.1 2012 2020
Percentage of the requests served within a certain time (ms)
50% 2012
66% 2013
75% 2015
80% 2015
90% 2018
95% 2019
98% 2019
99% 2020
100% 2020 (longest request)
항목 | 값 | 해석 |
---|---|---|
Complete requests | 100 | 총 100개의 요청 성공 |
Concurrency Level | 20 | 한 번에 20개씩 요청 |
Time taken | 11.136초 | 100개 요청 처리하는 데 걸린 총 시간 |
Requests/sec | 8.98 | 초당 평균 처리된 요청 수 |
Time per request | 2227ms | 한 요청당 평균 걸린 시간 (전체 기준) |
Processing max | 2020ms | 가장 오래 걸린 요청은 2초 걸림 |
50% 요청 시간 | 2012ms | 절반이 2초 이상 걸림 |
동기 방식 느낀점
동기 방식은 은행과 동일합니다.
손님과 은행원이 1:1로 매칭되어 손님이 요청한 작업이 시간이 걸려도 은행원은 다른 손님의 작업을 할 수 없습니다.
동기방식의 장점은 스레드로 들어온 작업은 순차적으로 진행이 된다는 것입니다.
예외가 발생하거나 오류가 발생해도 해당 지점은 동일한 스레드 내에서 발생했기때문에 제일 외곽에서 예외를 잡을 수 있다는 큰 장점이 있습니다.
편하게 사용하는 RestControllerAdvice
와 같이 예외에 대한 공통적인 처리도 가능하게 됩니다.
일관성도 다루기가 쉽습니다. 예외가 발생하면 그동안 동작한 방식을 트랜잭션 기능을 되돌릴 수 있습니다.
그리고 요청에 대한 병목구간도 명확합니다.
운영체제 → WAS → 서블릿 → 데이터베이스
WAS 이후로는 모두 동일한 스레드에서 동작하기 때문에 프로세스 흐름이 무척 간단합니다.
장점은 구현이 간단하다, 예외 처리가 쉽다, 일관성을 유지하기 좋다.
단점은 요청과 스레드가 1:1 매칭이 되다보니 제한된 스레드풀에서 한번 요청에 스레드가 할당되면서 스레드 풀을 모두 사용한다면 확장하기가 어렵습니다.
여기서 말하는 확장성이 떨어진다는 건 현재 구조에서 어떤 방식을 사용하더라도 이미 스레드가 부여되고 반환되기까지 시간을 줄이지 않으면 구조의 한계가 있습니다.
비동기 I/O 테스트
@Slf4j
@RestController
class AsyncController {
private final AsyncService asyncService;
public AsyncController(AsyncService asyncService) {
this.asyncService = asyncService;
}
@GetMapping("/async/limit")
public CompletableFuture<String> handleRequest() {
log.info("Async limit");
return asyncService.asyncOperation();
}
}
@Slf4j
@Service
class AsyncService {
@Async("limitedExecutor")
public CompletableFuture<String> asyncOperation() {
CompletableFuture<String> future = new CompletableFuture<>();
try {
log.info("{} - start", Thread.currentThread().getName());
TimeUnit.SECONDS.sleep(3);
log.info("{} - end", Thread.currentThread().getName());
future.complete("finished");
} catch (InterruptedException e) {
future.completeExceptionally(e);
}
return future;
}
}
@Bean("limitedExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // intentionally small pool
executor.setMaxPoolSize(2);
executor.setQueueCapacity(10); // queue overflows beyond 10
executor.setThreadNamePrefix("async-exec-");
executor.initialize();
System.out.println("executor = " + executor);
return executor;
}
Server Software:
Server Hostname: localhost
Server Port: 8080
Document Path: /async/limit
Document Length: 8 bytes
Concurrency Level: 20
Time taken for tests: 21.030 seconds
Complete requests: 100
Failed requests: 87
(Connect: 0, Receive: 0, Length: 87, Exceptions: 0)
Non-2xx responses: 87
Total transferred: 20699 bytes
HTML transferred: 9848 bytes
Requests per second: 4.76 [#/sec] (mean)
Time per request: 4206.006 [ms] (mean)
Time per request: 210.300 [ms] (mean, across all concurrent requests)
Transfer rate: 0.96 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.3 0 1
Processing: 1 1293 3871.8 1 18024
Waiting: 1 1293 3871.7 1 18023
Total: 1 1293 3871.9 1 18024
Percentage of the requests served within a certain time (ms)
50% 1
66% 1
75% 3
80% 3
90% 6012
95% 12014
98% 18024
99% 18024
100% 18024 (longest request)
항목 | 값 | 해석 |
---|---|---|
Complete requests | 100 | 총 100개의 요청을 보냄 |
Failed requests | 87 | 87개의 요청이 실패함 (응답 길이 오류) |
Concurrency Level | 20 | 한 번에 20개의 요청을 동시에 실행 |
Time taken for tests | 21.030초 | 100개 요청을 처리하는 데 총 21.03초가 걸림 |
Requests per second | 4.76 req/sec | 초당 평균 처리된 요청 수, TPS 약 4.76 |
Time per request | 4206ms | 각 요청이 순차적으로 처리될 경우 평균 4.2초 소요 |
Time per request (across all) | 210ms | 병렬 요청 기준으로 각 요청 평균 약 210ms 소요 |
Transfer rate | 0.96 KB/s | 평균 초당 수신한 데이터 양 |
Processing max | 18024ms | 가장 오래 걸린 요청은 18초 걸림 |
50% 요청 시간 | 1ms | 절반은 응답이 거의 즉시 옴 |
90% 요청 시간 | 6012ms | 상위 10%는 6초 이상 걸림 |
100% 요청 시간 | 18024ms | 가장 늦은 응답은 18초 소요 (최악) |
초기 요청 (큐 여유 有)
└─ 큐 적재 → 순차 처리 (Processing 시간 ↑)ㄹㄹ
상황을 정리하면 백화점에서 역대급 할인 이벤트를 해서 수 많은 손님이 몰렸고
백화점에 먼저 도착한 손님을 줄을 세워서 선착순 번호표를 받은 사람들을 제외하고 모두 돌려보냈다.
그리고 선착순 번호표를 받은 사람들은 자기가 돌아가기 전까지는 계속 기다리기 때문에 마지막 번호표를 받은 사람은 18초가 걸린 것이다
정리
비동기 방식에서도 결국 서버의 처리량은 내부의 대기 큐의 크기와 워커 스레드 수에 따라 결정됩니다.
자바스크립트는 단일 스레드 환경에서 여러 작업을 이벤트 루프와 큐를 통해 비동기적으로 분산 처리합니다. 타이머, I/O, UI 등 이벤트 종류에 따라 큐에 분류되고, 실행 가능한 시점이 되면 순차적으로 처리됩니다.
이 과정에서 큐가 일시적으로 넘치더라도, 자바스크립트(특히 브라우저 환경)는 사용자의 편의성을 위해 일이 많아도 “천천히라도 보여주자”는 UX 중심의 방식을 택합니다. 그래서 때로는 브라우저가 멈춘 것처럼 보이지만, 실제로는 이벤트 큐가 소화되지 않아 UI가 지연되는 현상이 발생합니다.
하지만 서버는 다릅니다.
서버는 사용자 수백~수천 명의 요청을 처리하기 때문에, 큐가 넘쳤을 때 “천천히 처리하자”는 방식은 시스템 지연 → 자원 고갈 → 전체 장애로 이어질 수 있습니다.
따라서 서버에서는 처리 가능 범위 내에서만 요청을 수용하고, 그 이상은 즉시 실패로 응답하는 전략이 필요합니다.
이를 위한 대표적인 메커니즘이 서킷 브레이커(Circuit Breaker)입니다.
서킷 브레이커는 과부하 상태에서 빠르게 요청을 거절함으로써 시스템 전체의 안정성을 확보하고, 회복할 수 있는 여지를 남깁니다.
댓글남기기