브라우저에서 10개 이상의 동시 요청이 보내지지 않는다면 HTTP1.1을 사용하고 있지 않은지 확인해보세요 (^__ ^)..
몇일 전... 한 페이지 안에서 웹소켓을 여러개 연결하여 connection 별로 데이터를 받아 계속 띄워줘야하는 상황에서 아무리해봐도 웹 소켓 connection 24개를 모두 완료(connection 요청부터 upgrade로 웹소켓 connection 완료까지)하는데 5초 이상이 걸린다는 팀원(a.k.a. 선생님)분의 고민을 들었다. 페이지 들어가서 5초를 기다려야 데이터도 아니고 '연결'이 완료된다...? 느리다....
웹소켓 하나를 연결할 때 약 0.01초가 걸린다.
5개도 0.1초가 안됨
10개도 0.176초
근데 갑자기 20개는 5초......?!?!?!?
수치로만 봤을 때 어느 순간 갑자기 모든 연결 완료 시간이 지수함수마냥 늘어난다.
연결시간 뻥튀기의 원인 추측해보기
처음에는 서버 내에서 당연히 연결을 병렬적으로 처리할거라고 생각했으나 총 소요시간을 보고 병렬이....아닌가??...? 왜 시간상으로는 동기적으로 한 연결 끝나고 하나 처리하고, 끝나면 또 하나 처리하는 것 같은 시간이 나오지? 라는 의문을 가졌다.
1. 병렬로 처리되지 않는다.
알고보니... 병렬로 처리되지 않았다...? (오죽하면 이런 생각을......ㅠㅠㅠㅠㅠㅠ)
2. 웹소켓에 할당된 스레드가 따로 있나...?
사용할 수 있는 스레드 수가 정해져있고 이것보다 많은 요청을 보내서 지연되었나? (겠냐고.....)
3. non-blocking을 사용하면 문제가 해결될 것이다.
(스레드 풀이 소켓 요청 갯수보다 적을 때) 톰캣은 기본적으로 blocking이니까 작업이 완료될 때까지 스레드가 대기하게 되어서 느려졌을 수 있지 않을까....?
추측만해서는 원인을 알 수 없다. 직접 손으로 뛰며 알아보자.
1 → 소켓 연결과정 로그로 찍어서 순차처리 되는지 확인해보기
2 → 실행 스레드 확인해보기
3 → 스레드 풀 크기 조정해보기
1, 2, 3 번의 진위를 확인하기 위해서 간단한 웹소켓 서버를 만들고 스레드 풀 크기를 조정해가면서 소요 시간과 로그 순서를 확인해보았다.
우선 로그는 꼭 소켓 연결 요청 -> 완료 -> 요청 -> 완료 순으로 순차적으로 찍히진 않았다. 다음으로 스레드 풀을 조정해보자.
스레드 풀 사이즈와 크게 상관 없어 보인다.
흠..
다음으로 2번을 확인해보기 위해서 웹소켓 연결에 쓰는 스레드를 직접 확인해보기로 했다.
스레드 풀 사이즈를 min 100, max 200 으로 두었을 때 당연하게도 30개 모두 각자 다른 스레드를 사용한다.
연결수보다 max thread 수가 작아도 골고루 쓰면서 심지어 (100, 200) 일 때와 시간도 별로 차이나지 않는다.
3 → netty + spring webflux 기반으로 웹소켓 요청 처리하기
마지막 남은 희망은 non-blocking......!!
웹소켓도 연결을 위해서 서버와 클라이언트 간의 통신 채널을 열고 데이터를 주고 받는 I/O 작업을 하니까
non-blocking으로 웹소켓 연결을 처리하면 더 빠르게 모든 요청이 완료되지 않을까??
절망....
어째서...? 왜 때문에...?
어떤 방법으로 구현해봐도 웹소켓 연결 요청에 대한 빠른 최적화가 이루어지지 않았다.
이런 결과를 팀원분과 공유한 결과 팀원분이 혹시 브라우저 병목일까요? 라는 아이디어를 주셨다.
그래서 귀찮아서 슬쩍 미뤄뒀던 test code를 바로 작성해보기로 했다.
testcode 결과 확인해보기. 어쩌면 서버는 아무 문제 없었을지도....?
testcode는 다음과 같다.
package org.san.netty.websocket;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient;
import reactor.core.publisher.Mono;
class NettyWebSocketTest {
Logger logger = LoggerFactory.getLogger(NettyWebSocketTest.class);
@Test
public void testWebSocketConnections() throws InterruptedException, ExecutionException {
ReactorNettyWebSocketClient client = new ReactorNettyWebSocketClient();
URI url = URI.create("ws://localhost:8080/ws");
ExecutorService executorService = Executors.newFixedThreadPool(30);
List<Future<Void>> futures = new ArrayList<>();
long startTime = System.nanoTime(); // 시작 시간 기록
for (int i = 0; i < 30; i++) {
futures.add(executorService.submit(() -> {
client.execute(url, session ->
session.send(Mono.just(session.textMessage("Hello")))
.thenMany(session.receive()
.take(1)
.map(msg -> {
logger.info(
"sessionId" + session.getId() + " Received: " + msg.getPayloadAsText());
return msg;
}))
.then()
).block(Duration.ofSeconds(10));
return null;
}));
}
// Wait for all futures to complete
for (Future<Void> future : futures) {
future.get();
}
executorService.shutdown();
if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
long endTime = System.nanoTime(); // 종료 시간 기록
long duration = endTime - startTime; // 총 소요 시간 계산
logger.info("Total time for all connections: " + (duration / 1_000_000) + " ms");
}
}
쟉고...보잘 것 없지만... 이 코드는 여기서 볼 수 있습니다...
1. tomcat + spring-mvc 서버에 대해 동시에 웹소켓을 30개 연결하는 test code 실행
쟉고...보잘 것 없지만... 이 코드는 여기서 볼 수 있습니다...
2. netty + spring-webflux 서버에 대해 동시에 웹소켓을 30개 연결하는 test code 실행
쟉고...보잘 것 없지만... 이 코드는 여기서 볼 수 있습니다...
..........그러하다 우리 서버는 아무 잘못 없었던 것이다...
서버에서는 우리가 예측, 그리고 원했던 수치안에 웹소켓 연결을 모두 완료할 수 있었다. 그렇다면 브라우저에서 요청을 보내는데 병목이 일어나는 원인이 뭐가 있을까? 그래서 브라우저 병목 원인을 검색.. 아니 물어봤다.
네...? 브라우저의 동시 연결 제한이요???
원인은 바로 ... 너! http 1.1 에서 브라우저의 동시 요청 제한
웹소켓 handshake는 기본적으로 http 요청으로 시작한다. 먼저 웹소켓 connection으로 upgrade 해달라는 요청을 클라이언트가 http 요청으로 보내는데 기본적인 스펙은 다음과 같다.
서버가 웹소켓 연결을 허용하면
다음과 같은 응답을 보내고 웹소켓 connection을 맺게 되는데 여기서 지금 중요한건 기본적으로 http 1.1로 요청을 보낸다는 것이다.
http 1.1의 connection 관리
HTTP/1.1은 기본적으로 요청과 응답 사이에 하나의 요청이 완전히 처리될 때까지 기다려야 하는 순차적인 방식을 사용한다. 따라서 여러 개의 요청이 동시에 전송되더라도, 서버는 한 번에 하나의 요청을 처리하고 응답을 보내야 한다. 이런 방식은 요청 대기 시간이 늘어나고, 효율성과 성능이 저하되기 때문에 브라우저들은 각 도메인에 대한 몇 개의 커넥션을 맺고 병렬로 요청을 보내서 좀 더 빠른 응답을 만들고자 했다. 지금은 일반적으로 6-8 개의 연결이 병렬로 가능하다. 그러니까..... 같은 도메인에 보내는 웹소켓은 브라우저 내에서 순차적으로 보내고 있었고, 5초라는 결과도 브라우저가 힘써서 조금의 병렬 처리를 지원했기 때문에 그나마 5초였던 것!
이런 단점을 극복하고자 도메인 샤딩이라는 방법을 쓸 수 있다. 도메인을 여러 개의 하위 도메인으로 분할하여 요청을 나눠보내는 것이다.
하지만 이 기술은 deprecated 되었으며 추천하지 않는다. 각 도메인에 대한 추가적인 DNS 조회비용과 TCP 연결 설정에 따른 오버헤드가 발생한다는 문제점이 있기 때문이다.
이제 도메인 샤딩을 적용하기보다는 멀티 플렉싱을 통해 다른 HTTP 요청을 동일한 TCP 연결에 다중화할 수 있는 기능을 제공하는 http2를 쓰기를 권장한다. http1.x 보다 업그레이드 된 http2는 동시 요청 제한도 받지 않는다.
다행스럽게도 웹소켓 연결 요청은 http1.1 이상 버전이기만 하면 보낼 수 있기 때문에 서버가 http2를 지원한다면 http2로 웹소켓 연결 요청을 보낼 수 있다. 다만 tls/ssl 을 설정해야한다는 점~~~~~~
우리는 이렇게 하기로 했어요. http 2 도입
도메인 샤딩과 http2라는 방법 중에 이미 tls가 적용되어 있고 서버도 http2를 지원할 수 있으므로 http2로 요청을 보내기로 했다!
행복 결론~
서버를 만든다고 모든 문제점을 서버에서만 찾고 있었는데 시야를 좀 넓게 두고 다양하게 생각할 필요성을 느꼈다. 또, 돌아보면 단순 웹소켓 연결 완료 시간만 잴게 아니라 네트워크 탭에서 waterfall을 보면 연결이 순차적으로 갔다는 걸 알 수 있겠다 싶었는데 지금 확인해보니까 웹소켓에 대한 건 안나오네...? 개발자 도구도 좀 공부해야겠다....ㅎㅎ..ㅠㅠ
https://developer.mozilla.org/ko/docs/Web/HTTP/Connection_management_in_HTTP_1.x