서버는 빠른데 왕복이 느립니다 (Pipelining 과 RTT)

Redis 명령 하나는 마이크로초인데 천 번을 부르면 느려집니다. 범인은 서버가 아니라 네트워크 왕복이에요. RTT 가 쌓이는 구조, TCP 의 Nagle 과 delayed ACK 가 얹는 지연, 파이프라이닝이 이벤트 루프에서 시스템 콜을 어떻게 접는지, 그리고 응답 버퍼가 메모리를 먹는 함정까지 정리했습니다.

[배경 - 명령은 빠른데 전체가 느렸다]

Redis 명령 하나는 아주 빠릅니다. 그런데 반복문 안에서 키 천 개를 하나씩 GET 하면 체감이 확 느려져요. 서버 로그를 보면 각 명령은 여전히 마이크로초 단위인데, 전체 작업은 그것과 안 맞게 오래 걸립니다.

처음에는 Redis 가 느린 줄 알았어요. 그런데 원인은 Redis 바깥, 정확히는 클라이언트와 서버 사이의 네트워크 왕복에 있었습니다. 명령을 하나 보내고 응답을 받는 이 왕복이 천 번 쌓이면, 서버가 아무리 빨라도 그 벽에 막혀요.

이 글에는 제가 잰 숫자가 없습니다. 나오는 값은 Redis 의 기본 설정값이거나 널리 문서화된 OS 동작이고, 각각 어느 쪽인지 표시해뒀어요. 그래서 이 왕복이 무엇이고, 그 아래 TCP 층이 무엇을 얹으며, 파이프라이닝이 그중 무엇을 없애는지, 그리고 그 대가로 무엇을 조심해야 하는지 순서대로 봤습니다.

[문제 상황 분석 - RTT 가 쌓인다]

RTT(Round Trip Time)는 명령 하나를 보내고 응답을 받기까지의 왕복 시간입니다. 전송, 서버 처리, 응답으로 이뤄지고, 물리적 거리가 이 값을 지배해요. 문제는 명령을 순차로 보낼 때예요. 앞 명령의 응답을 받아야 다음 명령을 보내면, 명령이 N 개일 때 RTT × N 이 그대로 쌓입니다.

순차는 왕복을 N 번 반복하고, 파이프라인은 한 번에 몰아 왕복을 한 번에 가깝게 만든다 순차 client server 명령 1 왕복, 명령 2 왕복 … 총 RTT × N 파이프라인 한꺼번에 보내고 한꺼번에 받음, RTT × 1 에 가까움

여기서 한 가지 짚을 게 있어요. 순차 반복의 지연은 서버가 바쁘거나 명령이 무거워서 생기는 게 아닙니다. 서버는 각 명령을 마이크로초에 끝내고 나머지 시간은 그저 다음 명령이 오기를 기다려요. 그러니까 낭비되는 건 서버 시간이 아니라 왕복 시간입니다. 이 구분이 뒤의 모든 이야기의 출발점이에요.

[그 아래 TCP 가 얹는 지연 - Nagle 과 delayed ACK]

RTT 를 물리 거리만의 문제로 보면 절반만 본 겁니다. 그 아래 TCP 층이 작은 요청에 지연을 한 겹 더 얹거든요. 원인은 두 가지 최적화가 서로 엇물리는 데 있습니다.

Nagle 알고리즘은 작은 데이터를 곧장 보내지 않고 잠깐 모읍니다. 아직 ACK 를 못 받은 이전 데이터가 있으면, 새 작은 조각을 버퍼에 쥐고 있다가 ACK 가 오거나 조각이 충분히 커지면 그때 내보내요. 작은 패킷이 네트워크를 채우는 걸 막으려는 장치입니다.

delayed ACK 는 받는 쪽의 최적화예요. 데이터를 받자마자 ACK 를 보내지 않고, 되돌려줄 응답에 ACK 를 얹어 보내려고 잠깐 기다립니다. 이 대기 시간은 OS 설정에 따라 다른데 보통 수십 밀리초 수준으로 알려져 있어요.

문제는 이 둘이 겹칠 때입니다. 보내는 쪽은 ACK 를 기다려 다음 조각을 붙잡고 있고, 받는 쪽은 응답에 얹으려고 ACK 를 미뤄요. 서로가 서로를 기다리다 수십 밀리초가 그냥 흘러갑니다. 명령 하나하나가 작은 요청인 Redis 같은 워크로드에서 이건 마이크로초짜리 명령에 밀리초짜리 세금을 매기는 셈이에요.

그래서 Redis 는 클라이언트 소켓에 TCP_NODELAY 를 켜서 Nagle 을 끕니다. 작은 명령이라도 곧장 내보내라는 뜻이에요. 다만 이건 개별 왕복의 지연을 줄일 뿐, 왕복 자체를 없애지는 못합니다. 왕복의 횟수를 줄이는 건 다음에 볼 파이프라이닝의 몫이에요.

[파이프라이닝 - 응답을 기다리지 않는다]

파이프라이닝은 앞 명령의 응답을 기다리지 않고 명령을 한꺼번에 몰아 보낸 뒤, 응답도 한꺼번에 받는 방식입니다. 순서는 이래요.

  • 클라이언트가 명령들을 버퍼에 쌓습니다. 곧장 보내지 않아요.
  • 버퍼가 flush 되면 하나의 TCP 스트림으로 몰아 보냅니다.
  • 서버는 도착 순서대로 처리하고 응답을 응답 버퍼에 쌓아 되돌려줘요.
  • 클라이언트가 응답을 순서대로 각 명령에 매핑합니다.
버퍼에 모았다가 flush 될 때 한 번에 보내고, 응답도 한꺼번에 받아 순서대로 매핑한다 클라이언트 버퍼 GET a GET b GET c flush, 하나의 TCP 스트림으로 응답을 순서대로 각 명령에 매핑 Redis 서버 순서대로 처리, 응답 버퍼에 적재

결과적으로 RTT × N 이 RTT × 1 에 가까워집니다. 여기서 오해하기 쉬운 게 하나 있어요. 파이프라이닝은 서버를 더 빠르게 만드는 게 아닙니다. 서버가 하는 일의 양은 그대로예요. 낭비되던 네트워크 왕복이 사라지는 것뿐입니다.

[서버는 파이프라인을 모른다 - 이벤트 루프에서 보면]

여기가 자주 오해되는 지점이에요. 파이프라이닝은 순수하게 클라이언트 쪽의 배칭입니다. 서버에는 “파이프라인 모드” 같은 상태가 없어요. 서버가 보는 건 그냥 소켓에 도착한 바이트 스트림이고, 그 안에 명령이 하나 들었든 백 개가 들었든 똑같이 파싱해서 순서대로 실행합니다. 클라이언트가 응답을 기다리지 않고 계속 보낸다는 사실을 서버는 알지도, 알 필요도 없어요.

그런데 이 구조가 서버 쪽에서도 공짜 이득을 냅니다. 43번 글에서 본 이벤트 루프를 떠올리면 이유가 보여요. 서버는 소켓이 읽기 준비되면 read 로 바이트를 한 번에 긁어 옵니다. 파이프라인으로 명령 백 개가 한 스트림에 몰려 있으면, 그 백 개가 한 번의 read 로 들어와요. 그리고 응답은 곧장 소켓에 쓰이지 않고 출력 버퍼에 쌓였다가, 루프가 다시 대기에 들어가기 직전에 몰아서 write 됩니다.

그러니까 순차로 백 번 오갈 때 200번 나던 시스템 콜(read 100, write 100)이, 파이프라인에서는 몇 번으로 접혀요. 왕복을 줄이려고 시작한 게 시스템 콜 오버헤드까지 같이 줄이는 셈입니다. 정확히는 파이프라이닝의 이득은 네트워크 왕복 절감이 주고, 시스템 콜 절감이 부수적으로 따라옵니다.

[파이프라이닝은 트랜잭션이 아니다]

이 둘은 여러 명령을 묶는다는 점이 닮아서 헷갈리기 쉬운데, 목적이 완전히 다릅니다.

항목PipeliningTransaction (MULTI)
목적네트워크 왕복 절감원자적 실행
원자성없음있음
실행명령이 각각 독립 실행EXEC 시점에 한꺼번에
중간 끼어듦다른 클라이언트가 끼어들 수 있음완전 격리
부분 성공허용됨큐 전체가 함께 처리

정확히는 파이프라이닝은 왕복 최적화이고, 트랜잭션은 원자적 실행이에요. 파이프라인으로 묶은 명령들 사이에는 다른 클라이언트의 명령이 끼어들 수 있고, 일부만 성공할 수도 있습니다. 트랜잭션의 격리와 롤백 없는 원자성은 58번 글에서 따로 다뤘어요.

[에러는 조용히 묻힌다]

원자성이 없다는 말에는 실무에서 물리기 쉬운 함정이 하나 딸려 옵니다. 파이프라인은 명령 하나가 에러를 내도 멈추지 않아요. 그 명령의 응답 자리에 에러가 담길 뿐, 뒤 명령들은 그대로 실행됩니다.

문제는 클라이언트가 응답을 어떻게 받느냐예요. 파이프라인의 결과는 명령 순서대로 늘어선 응답 배열입니다. 클라이언트가 이 배열을 하나씩 검사하지 않고 “예외가 안 났으니 다 됐겠지”라고 넘어가면, 중간에 섞인 에러 응답이 조용히 묻혀요. 순차 실행이었다면 그 자리에서 예외로 튀었을 실패가, 파이프라인에서는 배열의 세 번째 원소로 얌전히 들어앉아 있는 겁니다.

그래서 파이프라인을 쓸 때는 응답 배열을 끝까지 훑어 각 명령의 성패를 확인하는 습관이 필요해요. 라이브러리에 따라 에러 응답을 예외로 올려주기도 하고 값으로 돌려주기도 하니, 쓰는 클라이언트가 어느 쪽인지 아는 게 먼저입니다.

[버퍼가 메모리를 먹는다 - 파이프라인을 무작정 키우면]

파이프라인이 좋다고 명령 수만 개를 한 번에 몰면 다른 벽에 부딪힙니다. 보내는 요청과 받는 응답이 모두 버퍼에 쌓이기 때문이에요.

받는 쪽부터 보면, 서버는 파이프라인으로 들어온 명령의 응답을 클라이언트 출력 버퍼에 쌓아둡니다. 그런데 이 출력 버퍼는 43번 글에서 봤듯 Redis 의 메모리예요. 게다가 일반 클라이언트의 제한은 기본이 풀려 있습니다.

client-output-buffer-limit normal 0 0 0

normal 이 0 0 0, 그러니까 제한 없음이에요. 파이프라인으로 대량 명령을 밀어 응답이 거대해지면, 그 응답이 소켓으로 다 빠져나가기 전까지 서버 메모리를 그만큼 붙잡습니다. 여기에 값 하나의 크기 상한인 proto-max-bulk-len(기본 512MB, Redis 기본값)까지 겹치면, 큰 값을 대량으로 오가는 파이프라인은 메모리를 예상보다 많이 씁니다.

보내는 쪽도 마찬가지예요. 클라이언트가 명령 수만 개를 버퍼에 쌓아 한 번에 flush 하면 그 버퍼가 클라이언트 메모리를 먹고, 응답이 다 올 때까지 그걸 들고 있어야 합니다. 그래서 실무에서는 파이프라인을 보통 수천 개 단위로 배치를 쪼개요. 왕복을 줄이려다 메모리를 키우는 함정을 피하는 겁니다. 왕복 절감의 이득은 처음 수백에서 수천 개 구간에서 대부분 나오고, 그 뒤로는 메모리 부담만 늘어나는 경우가 많아요.

[MGET, MSET - 명령 자체가 왕복 하나]

파이프라이닝만이 왕복을 줄이는 길은 아닙니다. Redis 에는 여러 키를 한 명령으로 다루는 가변 인자 명령이 있어요. MGET key1 key2 key3 은 키 세 개를 한 번의 왕복으로 가져오고, MSET 은 여러 키를 한 번에 씁니다. HMGET, SADD 에 여러 멤버를 넘기는 것도 같은 결이에요.

이쪽은 파이프라이닝과 결이 다릅니다. 가변 인자 명령은 서버에서 하나의 명령으로 원자적으로 실행되고, 왕복도 한 번이에요. 그래서 같은 종류의 키를 여럿 다룰 때는 파이프라인보다 이쪽이 더 단순하고 안전합니다. 정확히는 명령 하나로 표현되는 작업이면 가변 인자 명령을, 서로 다른 명령을 여러 개 묶어야 하면 파이프라인을 쓰는 게 맞아요. 다만 클러스터에서는 여러 키가 같은 슬롯에 있어야 이 명령들이 도니, 그 제약은 따로 챙겨야 합니다.

[둘을 겹치기]

파이프라이닝과 트랜잭션이 배타적인 건 아닙니다. 많은 클라이언트가 MULTI, EXEC 를 파이프라인에 실어 보내요. 원자성은 트랜잭션에서, 왕복 절감은 파이프라이닝에서 동시에 얻는 흔한 패턴입니다. 조건 로직이 낀 원자 연산이면 Lua 스크립트를 파이프라인에 실어 보내는 조합도 같은 이유로 자주 쓰여요.

[실무 적용 - 왕복을 줄이되 버퍼를 지킨다]

정리하면 규칙은 몇 가지로 좁혀집니다.

1. 왕복이 지배적인 대량 작업에 파이프라인을 씁니다. 키 수천 개를 하나씩 오가는 반복문이 대표적인 후보예요. 명령이 무거워서 느린 경우에는 파이프라인이 답이 아닙니다.

2. 배치를 수천 개 단위로 쪼갭니다. 한 번에 수만 개를 몰면 송수신 버퍼가 메모리를 먹어요. 왕복 절감의 이득은 대부분 앞 구간에서 나옵니다.

3. 응답을 끝까지 검사합니다. 파이프라인은 원자적이지 않아서 중간 에러가 배열 안에 조용히 들어앉아요. 각 응답의 성패를 확인해야 실패를 놓치지 않습니다.

4. 원자성이 필요하면 트랜잭션이나 Lua 를 실어 보냅니다. 파이프라인 자체는 격리를 주지 않아요. 원자적 실행이 필요한 묶음은 MULTI, EXEC 나 Lua 로 감싸 파이프라인에 태웁니다.

5. 단순한 다중 키는 MGET, MSET 을 먼저 봅니다. 명령 하나로 표현되면 그게 파이프라인보다 단순하고 원자적이에요.

6. 지연이 어디서 오는지부터 확인합니다. 왕복 때문인지, 명령이 무거워서인지, TCP 설정 때문인지 나눠 봐야 해요. 파이프라인은 왕복 문제에만 듣습니다.

[결론]

파이프라이닝은 서버를 손대지 않고 네트워크 왕복을 줄이는 방법이었습니다. RTT 가 N 번 쌓이던 걸 한 번에 가깝게 접고, 그 아래 이벤트 루프에서는 시스템 콜까지 같이 줄여요. 서버는 이걸 알지도 못한 채 그냥 도착한 바이트를 순서대로 처리할 뿐입니다. 원자성이 필요하면 트랜잭션을, 조건 로직이 낀 원자 연산이면 Lua 를, 둘 다 필요하면 그것들을 파이프라인에 실으면 됩니다.

그리고 그 이득의 반대편에 대가가 있었어요. 원자성이 없어 에러가 조용히 묻힐 수 있고, 버퍼가 메모리를 먹어 무작정 키우면 안 됩니다. 왕복을 줄이려다 메모리를 키우거나 실패를 놓치는 게 흔한 함정이에요.

남은 한계를 적어둘게요. 저는 같은 작업을 순차와 파이프라인으로 돌려 실제 시간 차이를 재보지 않았고, 배치 크기를 바꿔가며 어디서 이득이 꺾이는지도 관찰하지 않았습니다. RTT 는 배포 환경의 네트워크 거리에 크게 좌우되니, 로컬에서 잰 값과 원격에서 잰 값은 완전히 다를 거예요. 그리고 Nagle 과 delayed ACK 가 실제로 얹는 지연은 OS 설정과 커널 버전에 따라 달라집니다. 다음에는 같은 반복 작업을 두 방식으로 돌려 redis-benchmark -P 로 파이프라인 깊이별 처리량을 재고, 절대값과 배수를 함께 적어 이 글을 제가 잰 값으로 보강하려고 합니다.