새로운 내용을 공부할 때
새로운 내용의 공부를 시작할 때 용어의 정의를 이해하지 못하거나 정확하게 알지 못한다면 그 용어가 포함된 문장을 이해하지 못합니다.
작은 단어 하나가 내용을 이해하지 못하게 하기 때문에 용어를 정확하게 이해하는 것이 중요합니다.

4 분 소요

실제로 서버 부하를 걸어보면서 테스트를 진행해보려고 합니다.

서버 지연 테스트

image-20250519235814346

동기 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)입니다.
서킷 브레이커는 과부하 상태에서 빠르게 요청을 거절함으로써 시스템 전체의 안정성을 확보하고, 회복할 수 있는 여지를 남깁니다.

댓글남기기