새로운 내용을 공부할 때
새로운 내용의 공부를 시작할 때 용어의 정의를 이해하지 못하거나 정확하게 알지 못한다면 그 용어가 포함된 문장을 이해하지 못합니다.
작은 단어 하나가 내용을 이해하지 못하게 하기 때문에 용어를 정확하게 이해하는 것이 중요합니다.
TIL) 비동기 스트리밍에 대해서 알아보자
각 스트림 모델의 작동 원리와 제약 조건을 이해함으로써, 시스템의 병목을 정확하게 알수있습니다.
서비스 특성에 맞는 효율적인 데이터 흐름(스트림) 설계를 할 수 있습니다.
스트림 종류
- 동기 스트림
- 비동기 스트림
- 리액티브 스트림
각 스트림마다 동작 원리를 파악하고 어떤 장점과 한계가 있으며 서비스 환경에 따라 스트림 방식을 선택할 수 있습니다.
공통
동기 스트림이나 비동기스트림, 리액티브 스트림은 모두 공통점이 있습니다
TCP/IP 프로토콜 기반 위에 동작하기에 TCP/IP 프로토콜이 어떤 방식으로 동작하는지 알아야합니다.
클라이언트(브라우저)가 서버와 통신하기 위해 네트워크 소켓을 생성합니다.
소켓에 connect()를 호출하게 되면 브라우저는 서버에 연결 요청을 보내게 되며,
운영체제는 연결 정보를 확인하여 관련된 포트에 리스너 소켓을 열어놓은 애플리케이션을 확인합니다.
우체국을 통해 우편물(웹 요청)을 보내려고 합니다.
우편 발송을 위해 배송지 정보와 동, 호수(포트)를 입력합니다.
클라이언트와 운영체제는 TCP/IP 통신으로 3handshake를 통하여 논리적인 연결이 됩니다.
연결되면 운영체제는 자신이 관리하는 파일 디스크립트(FD) 번호를 준비 큐에 넣어놓고 애플리케이션에게 알립니다.
애플리케이션 톰캣은 accept()
를 통해 준비 큐에 있는 파일 디스크립트 번호를 받습니다.
해당 번호에 인터페이스 write(), read()를 호출하게 되면 운영체제는 FD 번호를 통해 읽기 버퍼/쓰기 버퍼( 모두 커널 메모리) 에 데이터를 읽고 / 쓰게 됩니다.
이때부터 스트림의 종류에 따라 절차가 달라지게 됩니다.
클라이언트는 웹 요청을 HTTP 양식으로 보내건, SOAP로 보내건 상관없이 TCP/IP 세그먼트에 담아 전송하게 되는데
이때 세그먼트 용량은 정해져있습니다.
웹 요청이 작다면 1개의 세그먼트로 전달 될 수 있고, 웹 요청이 크다면 몇 십개의 세그먼트로 나누어 전달이 될겁니다.
운영체제는 클라이언트가 전달한 세그먼트 파일을 확인하여 순서를 확인하게 됩니다.
1번, 3번, 2번, … 이렇게 확인한 데이터를 재조립 하여 TCP 버퍼에 데이터를 저장하게 됩니다.
만약 2번, 1번, 5번, 4번이 도착하여 정렬해보니 3번이 없다는 것을 확인했습니다. 그러면 운영체제는 다시 3번부터 전달해달라고 합니다.
그 이후 1~2번을 먼저 재조립 하여 TCP버퍼에 저장하게 됩니다.
정리하면 클라이언트가 요청 파라미터를 택배 박스에 보낸다.
운영체제는 택배 박스를 뜯어서 TCP/IP 라벨지를 확인한다음 순서를 정렬해서
우리집 앞 경비실에 가져다 놓는다.
[클라이언트 (브라우저)]
└─ connect() 호출
↓
[운영체제 소켓 생성 + 3-way handshake]
↓
[서버 OS: FD 생성 → 준비 큐 등록]
↓
[톰캣: accept() 호출 → FD 수령]
↓
[클라이언트 요청이 NIC(Network Interface Card)를 통해 수신됨]
↓
[NIC가 인터럽트를 발생시켜 커널에 패킷 전달]
↓
[운영체제가 TCP 헤더를 확인하고 시퀀스 번호 기반으로 재조립]
↓
[재조립된 바이트 데이터를 TCP 수신 버퍼에 저장]
↓
[FD를 통한 read()/write()]
↓
[커널 수신/송신 버퍼로부터 사용자 메모리와 데이터 교환]
↓
[스트림 방식에 따라 처리 분기 (동기/비동기/리액티브)]
정리하면 클라이언트의 요청이 한번에 들어오는게 아니라
- (초기) TCP 커널 수신 버퍼가 생성됨
- 클라이언트로부터 세그먼트(패킷)가 도착함
- 운영체제가 시퀀스 번호를 기반으로 조립하여 TCP 버퍼에 저장함
- 애플리케이션이 read()를 호출하여 TCP 수신 버퍼에서 데이터를 읽음
- 읽힌 만큼 TCP 버퍼 공간이 비워짐 (버퍼 슬라이딩)
→ 2~5 반복
이 과정중 2~5번이 반복됩니다.
TCP스트림은 운영체제가 지속적으로 패킷을 조립하여 커널 수신 버퍼에 저장하고, 애플리케이션이 이를 반복해서 읽어가며 버퍼가 순차적으로 비워지는 흐름입니다.
확인할 수 있는 사실
TCP 수신 버퍼가 너무 작게 되면 어떤 문제가 생길까
-
시스템 콜 횟수가 증가하여 컨택스트 스위칭 비용이 증가합니다.
- 버퍼가 작으면 한번에 읽을 수 있는 양이 적기 때문에 더 많은 read() 시스템 콜을 호출하게 됩니다.
- read() 시스템 콜은 결국 커널모드와 유저 모드 전환이 필요하게 되므로 컨택스트 비용과 시스템 비용이 증가합니다.
동기 방식 : 스레드 블로킹 증가
비동기 방식 : 이벤트 루프 반복 횟수 증가 > backpressure 유발 가능합니다.
-
읽는 속도가 느려지게 됩니다.
- 커널 수신 버퍼가 꽉 찼는데 앱이 안 읽게 된다면 커널은 TCP 윈도우 크기를 0으로 줄여서 클라이언트에게 stop을 보내게 됩니다.
- 클라이언트는 전송을 멈추고 기다립니다
결과적으로 전송 속도가 느리고, 처리량이 감소합니다
-
멀티 커넥션 환경에서 OOM 위험이 줄 수 있지만 단건 처리 성능은 저하됩니다.
- 수신 버퍼를 너무 줄이면 메모리를 아낄 수 있습니다.
- 한 커넥션당 처리량이 떨어집니다.
- 특히 파일 다운로드나 동영상 스트리밍 처럼 대량 수신이 필요한 서비스에 병목이 됩니다.
즉 모든 커넥션이 한번에 읽을 수 있는 데이터 양이 작아지게 된다는 건
양동이로 떠오던 물을 물바가지에 떠오게 되므로 그만큼 여러번 읽게 됩니다.
TCP 수신 버퍼가 작아지면 시스템 콜 횟수가 늘어나고, 네트워크 처리량이 떨어지게 됩니다. 고속 전송이나 대용량 데이터 수신에서 성능 저하가 발생할 수 있습니다.
동기 스트림
서론
TCP 스트림에서 데이터는 클라이언트로부터 작은 패킷 단위로 도착하며,
운영체제는 이를 순서대로 재조립하여 TCP recv
수신 버퍼에 저장합니다.
이때 버퍼 크기는 처리량, 지연 시간, 시스템 자원 사용량에 직접적인 영향을 주기 때문에 매우 중요합니다.
동기 스트림(동기 I/O)
동기 스트림은 애플리케이션이 read()
를 호출했을 때, 커널 TCP 수신 버퍼에 데이터가 없으면 스레드가 대기(Blocking) 상태로 들어가는 구조입니다.
이는 I/O가 완료될 때까지 CPU 컨택스트 스위칭이 발생할 수 있음을 의미합니다.
- 애플리케이션이
read()
를 호출한다. - 커널은 해당 소켓의 TCP 수신 버퍼를 확인한다.
- 데이터가 있으면 바로 복사하여 반환.
- 데이터가 없으면 스레드는 블로킹 상태로 진입한다.
- 클라이언트로부터 TCP 세그먼트가 도착한다.
- 운영체제는 세그먼트를 재조립하고 TCP 수신 버퍼에 저장한다.
- 대기 중이던
read()
스레드가 깨어나서 데이터를 유저 공간으로 복사한다.
생각할 수 있는 상황
- 버퍼가 바로 채워진다면 ?
- 버퍼가 바로 채워지지 않는다면 ?
- 클라이언트의 요청 데이터가 작다면 ?
- 클라이언트의 요청 데이터가 크다면 ?
- 애플리케이션이 read()를 지속적으로 늦게 호출한다면?
- 여러 클라이언트가 동시에 요청을 보낸다면 ?
비동기 스트림
비동기 스트림 Asynchron I/O
비동기 스트림은 멀티플렉싱 기반으로 커널 이벤트를 감시합니다.
동기 스트림 처럼 각 커넥션마다 스레드가 직접 read()로 블로킹 대기하지 않습니다.
하나의 감시자 스레드(selector/epoll_wait)가 커널 이벤트를 감시합니다.
데이터 수신이 가능한 상태가 되면 이벤트를 알려주는 방식입니다.
동기 스트림은 커넥션 마다 read() 호출 시마다 스레드가 블로킹 상태가 됩니다.
비동기 스트림은 하나의 감시자 스레드가 모든 커넥션의 이벤트 감시하고 처리합니다.
-
스레드 A가 비동기 스트림 채널을 생성하거나 요청을 등록한다.
-
스레드 A는 read() 등으로 직접 대기하지 않고, 다른 코드를 계속 실행하거나 종료됨.
-
내부적으로 selector thread(예: Netty의 이벤트 루프)는 운영체제에 해당 채널을 epoll(또는 select)로 등록한다.
-
운영체제는 클라이언트로부터 TCP 세그먼트가 도착하면, 이를 재조립하여 커널 TCP 수신 버퍼에 저장한다.
-
데이터가 수신되면 커널은 해당 소켓 FD에 대해 읽기 가능 이벤트(EPOLLIN)를 발생시키고,
epoll의 wait queue에 등록된 selector thread를 wake_up()으로 깨운다.
-
깨어난 selector는 해당 이벤트가 발생한 소켓 FD들을 ready list에서 꺼내어, 사용자 애플리케이션에 알린다.
-
이때 애플리케이션은 해당 소켓에 대해 직접 read()를 호출하여 커널 TCP 버퍼에 있던 데이터를 ByteBuffer 등으로 복사하게 된다.
-
복사 이후에는 TCP 수신 버퍼가 지워지고(slide) 이 시점부터 프레이밍 로직(JSON 파싱, 길이 필드 기준 등)을 통해 의미 있는 메시지 단위로 처리할 수 있다.
댓글남기기