LabHub
배우기 러닝패스 코스

연결 하나가 느려지자 나머지가 전부 멈췄다 · 줄이 한 사람 때문에 멈춘다 · 이론

줄이 한 사람 때문에 멈춘다

LabHub 에서 이어서 보기

한 줄 요약

순차 서버에서는 연결 하나가 말을 늦게 시작하면 그 뒤에 선 연결이 모두 그 시간을 같이 기다립니다.

왜 이게 필요했나

연결 수천 개를 이야기하기 전에, 연결 두 개가 어떻게 서로를 막는지부터 봐야 합니다. 가장 흔한 서버의 모양은 accept 로 연결 하나를 받아 끝까지 처리하고, 다시 accept 로 돌아오는 반복문입니다. 이 구조는 읽기 쉽고, 손님이 한 명씩 차례로 올 때는 아무 문제가 없습니다. 문제는 손님이 겹칠 때가 아니라 앞사람이 느릴 때 드러납니다.

여기서 느리다는 말은 두 가지로 갈립니다. 하나는 서버가 그 요청을 처리하는 데 오래 걸리는 것이고, 다른 하나는 손님이 말을 늦게 시작하는 것입니다. 두 번째가 더 위험합니다. 서버는 아무 일도 하고 있지 않은데, recv 안에서 상대가 첫 바이트를 보내 주기를 기다리며 프로세스 전체가 멈춰 있기 때문입니다. 이때 CPU 사용률은 0에 가깝고, 로그에도 아무 줄이 남지 않습니다. 바쁜 것과 막힌 것은 겉보기 지표가 거의 같습니다.

앞 코스에서 바이트 경계와 재접속을 다뤘다면, 이번에는 그보다 앞선 질문을 다룹니다. 한 프로세스가 연결 여러 개를 동시에 들고 있으려면 무엇이 달라져야 하는가입니다.

어떻게 동작하나

소켓 호출 가운데 멈출 수 있는 것은 정해져 있습니다. 블로킹 모드에서는 아래 세 가지가 모두 프로세스를 세웁니다.

| 호출 | 언제 멈추나 | 그동안 다른 연결은 |
| --- | --- | --- |
| accept | 듣기 큐가 비어 있을 때 | 새 연결을 못 받는다 |
| recv | 받은 바이트가 없을 때 | 받아 둔 연결도 못 읽는다 |
| send | 보낼 자리가 없을 때 | 응답을 못 보낸다 |

새 연결이 완전히 사라지는 것은 아닙니다. 커널은 3-way handshake 를 끝낸 연결을 듣기 큐에 담아 두었다가 accept 가 불릴 때 하나씩 건네줍니다. 그래서 손님 쪽에서는 connect 가 곧바로 성공한 것처럼 보이고, 응답만 오지 않습니다. 화면에는 접속은 되는데 느리다는 말로 나타납니다.

그 큐의 크기가 listen 의 backlog 인데, [listen(2)](https://man7.org/linux/man-pages/man2/listen.2.html) 은 이 값이 /proc/sys/net/core/somaxconn 보다 크면 조용히 그 값으로 깎인다고 적고 있습니다. 같은 문서에 따르면 Linux 5.4 부터 그 파일의 기본값은 4096 이고 그 이전 커널에서는 128 입니다. 즉 코드에 큰 숫자를 적어 두어도 실제 큐는 커널 설정이 정합니다. 그리고 큐를 늘리는 것은 기다리는 사람의 수를 늘리는 것일 뿐, 기다리는 시간을 줄이지 않습니다.

먼저 재 보는 것이 먼저입니다. 느린 손님 한 명을 먼저 붙이고 빠른 손님 여섯 명을 뒤에 세우면, 순차 서버에서는 여섯 명이 모두 느린 손님의 시간만큼 기다립니다. 실습에서 쓰는 측정기가 그 숫자를 이렇게 내놓습니다.

client 1: 1699 ms OK...blocked=6fast=0

blocked 는 1초를 넘긴 손님 수, fast 는 0.2초 안에 끝난 손님 수입니다. 다중화로 바꾸면 같은 부하에서 이 두 숫자가 뒤집힙니다. 바꾸기 전에 재 두지 않으면 무엇이 나아졌는지 말할 수 없고, 나아지지 않았을 때도 알 수 없습니다.

블로킹 자체가 나쁜 설계는 아닙니다. [Python 소켓 HOWTO](https://docs.python.org/3/howto/sockets.html) 도 블로킹 소켓으로 시작해 설명합니다. 다만 블로킹 모드에서는 한 번에 연결 하나만 다룰 수 있고, 여러 연결을 동시에 다루려면 연결마다 실행 흐름을 따로 주거나(스레드·프로세스) 멈추지 않는 호출로 바꾸어야 합니다. 이 코스는 두 번째 길을 갑니다.

연결마다 스레드를 주는 길도 실제로 많이 쓰입니다. 코드가 블로킹 모양 그대로라 읽기 쉽다는 큰 장점이 있습니다. 다만 스레드는 각각 스택을 가지고 문맥 교환에 비용이 들어서, 목표가 연결 수천 개라면 처리량보다 자원 쪽에서 먼저 한계에 닿습니다. 더 중요한 것은 두 길이 같은 문제를 다르게 푼다는 점입니다. 스레드는 기다리는 자리를 연결 수만큼 늘리고, 다중화는 기다리는 자리를 하나로 모읍니다. 어느 쪽이든 핵심은 같습니다. 한 연결의 기다림이 다른 연결의 진행을 막지 않게 만드는 것입니다.

현장에서 만나는 모습

장애 보고가 이런 모양으로 들어옵니다. 서버 CPU 는 한가한데 응답 시간의 꼬리만 길고, 재시작하면 잠깐 좋아졌다가 다시 나빠집니다. 헬스체크는 통과합니다. 헬스체크를 보내는 쪽은 곧바로 요청을 보내 주는 성실한 손님이라, 자기 차례가 오면 빠르게 답을 받기 때문입니다.

원인은 대개 느린 손님 몇입니다. 모바일 회선에서 요청 헤더가 늦게 도착하거나, 클라이언트가 연결만 미리 열어 두고 나중에 쓰거나, 악의적으로 한 바이트씩 보내는 경우입니다. 순차 서버에서는 이런 손님 한 명이 서비스 전체의 처리량을 자기 속도에 묶습니다. 평균 지연만 보고 있으면 이 상황이 보이지 않습니다. 막힌 사람들의 시간은 평균 뒤에 숨습니다.

다음 퀴즈에서 할 것

직접 눈으로 그려 보세요. 느린 손님이 2초 뒤에 말을 시작하고 빠른 손님 여섯이 0.3초에 도착했다면, 순차 서버에서 여섯 명의 응답은 언제 나갈까요. 그들의 connect 는 언제 성공했을까요. 이 둘이 다른 시각이라는 점이 이 모듈의 핵심입니다. 퀴즈에서는 어떤 호출이 프로세스를 세우는지, 듣기 큐가 무엇을 늦추고 무엇을 늦추지 못하는지, 그리고 backlog 를 키우는 것이 왜 해결이 아닌지를 확인합니다.