Redis는 클라이언트-서버 모델과 요청/응답 프로토콜을 사용하는 TCP 서버이다. 일반적으로 요청은 다음 단계로 이루어진다.
예를 들어 아래의 네 개의 명령 시퀀스가 있다.
Client: INCR X
Server: 1
Client: INCR X
Server: 2
Client: INCR X
Server: 3
Client: INCR X
Server: 4
클라이언트와 서버는 네트워크 링크를 통해 연결된다. 링크는 매우 빠르거나(루프백 인터페이스) 매우 느릴(두 호스트 사이에 많은 홉이 있는 인터넷을 통해 설정된 연결된 경우) 수 있다.
cf. 루프백 인터페이스란?
컴퓨터 자체로 연결되는 가상 인터페이스이다. 실제 네트워크 장치가 필요하지 않고, 컴퓨터 내부로 데이터를 다시 전달하는 역할을 한다. 127.0.0.1 주소를 이용하여 로컬호스트에 연결할 수 있다.
네트워크 지연 시간에 상관없이 패킷이 클라이언트에서 서버로 이동하고 서버에서 클라이언트로 다시 돌아와 응답을 전달하는 데는 시간이 걸린다. 이 시간을 왕복 시간(RTT, Round Trip Time)이라고 한다.
클라이언트가 연속적으로 많은 요청을 수행해야 할 때, 예를 들어 한 리스트에 많은 항목을 추가하거나 많은 키로 데이터베이스를 채우는 경우, RTT는 성능에 영향을 많이 미친다. 예를 들어, RTT 시간이 250밀리초인 경우(인터넷을 통한 매우 느린 링크의 경우), 서버가 초당 100,000개의 요청을 처리할 수 있다고 하더라도 최대 4개의 요청만 처리할 수 있다.
사용되는 인터페이스가 루프백 인터페이스인 경우, RTT는 일반적으로 밀리초 미만으로 훨씬 짧지만 연속으로 많은 쓰기 작업을 해야하는 경우에는 이마저도 시간이 많이 늘어난다.
다행히 이 사용 사례를 개선할 수 있는 방법이 있다.
요청/응답 서버는 이전 응답을 클라이언트가 아직 읽지 않았더라도 새로운 요청을 처리할 수 있는 방식으로 구현될 수 있다. 이렇게 하면 클라이언트가 이전 응답을 기다리지 않고도 서버에 여러을 명령 전송하고 한 번에 응답을 읽을 수 있다.
이를 파이프라이닝이라고 한다. 수십 년 동안 널리 사용되고 있는 기술이다. 예를 들어, POP3 프로토콜 구현에서는 파이프라이닝 기능을 지원하여, 이를 통해 서버로부터 새 이메일을 다운로드하는 과정이 매우 빨라질 수 있었다.
Redis는 초기 버전부터 파이프라이닝을 지원해왔으므로, 실행 중인 버전에 관계없이 Redis에서 파이프라이닝을 사용할 수 있다. 다음은 netcat 유틸리티를 사용한 예시이다.
$ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG
이번에는 각 호출마다 RTT 비용을 지불하는 것이 아니라 3개의 명령에 대해 한 번만 RTT 비용을 지불한다.
파이프라이닝을 사용하였을 때, 이전 예시의 작업 순서는 다음과 같다.
Client: INCR X
Client: INCR X
Client: INCR X
Client: INCR X
Server: 1
Server: 2
Server: 3
Server: 4
참고💡
클라이언트가 파이프라이닝을 사용하여 명령을 전송하는 동안, 서버는 메모리를 사용하여 응답을 강제로 대기열에 넣어야 한다. 따라서 파이프라이닝을 사용하여 많은 명령을 보내야 하는 경우에는 각각 적절한 수(예: 10,000개의 명령)를 포함하는 일괄 처리를 보내고, 응답을 읽은 다음 다시 10,000개의 명령을 보내는 식으로 보내는 것이 좋다. 속도는 거의 동일하지만 추가로 사용되는 메모리는 이 10,000개의 명령에 대한 응답을 대기열에 대기시키는 데 필요한 양만큼만 늘어난다.
파이프라이닝은 왕복 시간과 관련된 지연 비용을 줄이는 방법에서 그치지 않는다. 실제로 Redis 서버에서 초당 수행할 수 있는 작업 수를 크게 향상시킨다. 파이프라이닝을 사용하지 않을 때, 각각의 명령을 처리하는 데는 데이터 구조에 접근하고 응답을 생성하는 측면에서는 비교적 적지만, 소켓 I/O 측면에서는 매우 비용이 많이 들게 된다. 이는 read()
와 write()
시스템 호출을 포함하고 있으며, 유저 영역과 커널 영역 간의 전환이 일어나게 된다. 이 과정에서 발생하는 컨텍스트 스위치는 속도를 현저하게 저하시킨다.
파이프라이닝을 사용하면 일반적으로 여러 명령이 하나의 read()
시스템 호출로 한꺼번에 읽히고, 여러 응답이 하나의 write()
호출로 전송된다. 그 결과, 초기에는 파이프라인이 길어질수록 초당 총 쿼리 수가 거의 선형적으로 증가하고, 결국 아래 그림과 같이 파이프라이닝을 사용하지 않았을 때보다 10배 가량 높은 성능을 얻을 수 있다.
$ ping lge.com # LG전자 # 평균 RTT=150.572 ms
PING lge.com (76.223.18.25): 56 data bytes
64 bytes from 76.223.18.25: icmp_seq=0 ttl=245 time=8.745 ms
64 bytes from 76.223.18.25: icmp_seq=1 ttl=245 time=42.042 ms
64 bytes from 76.223.18.25: icmp_seq=2 ttl=245 time=283.384 ms
64 bytes from 76.223.18.25: icmp_seq=3 ttl=245 time=135.976 ms
64 bytes from 76.223.18.25: icmp_seq=4 ttl=245 time=176.372 ms
64 bytes from 76.223.18.25: icmp_seq=5 ttl=245 time=219.597 ms
$ ping skhynix.com # SK하이닉스 # 평균 RTT=86.417 ms
PING skhynix.com (20.196.144.36): 56 data bytes
64 bytes from 20.196.144.36: icmp_seq=0 ttl=115 time=9.676 ms
64 bytes from 20.196.144.36: icmp_seq=1 ttl=115 time=7.818 ms
64 bytes from 20.196.144.36: icmp_seq=2 ttl=115 time=8.750 ms
64 bytes from 20.196.144.36: icmp_seq=3 ttl=115 time=8.250 ms
64 bytes from 20.196.144.36: icmp_seq=4 ttl=115 time=47.495 ms
64 bytes from 20.196.144.36: icmp_seq=5 ttl=115 time=91.606 ms
64 bytes from 20.196.144.36: icmp_seq=6 ttl=115 time=335.978 ms
64 bytes from 20.196.144.36: icmp_seq=7 ttl=115 time=53.741 ms
64 bytes from 20.196.144.36: icmp_seq=8 ttl=115 time=94.027 ms
$ ping kia.com # 기아자동차 # 평균 RTT=284.401 ms
PING kia.com (209.198.180.28): 56 data bytes
64 bytes from 209.198.180.28: icmp_seq=0 ttl=52 time=143.855 ms
64 bytes from 209.198.180.28: icmp_seq=1 ttl=52 time=186.224 ms
64 bytes from 209.198.180.28: icmp_seq=2 ttl=52 time=235.422 ms
64 bytes from 209.198.180.28: icmp_seq=3 ttl=52 time=278.486 ms
64 bytes from 209.198.180.28: icmp_seq=4 ttl=52 time=325.061 ms
64 bytes from 209.198.180.28: icmp_seq=5 ttl=52 time=228.472 ms
64 bytes from 209.198.180.28: icmp_seq=6 ttl=52 time=276.008 ms
64 bytes from 209.198.180.28: icmp_seq=7 ttl=52 time=518.370 ms
64 bytes from 209.198.180.28: icmp_seq=8 ttl=52 time=366.124 ms
$ ping celtrion.com # 셀트리온 # 평균 RTT=231.204 ms
PING celtrion.com (66.81.203.196): 56 data bytes
64 bytes from 66.81.203.196: icmp_seq=0 ttl=52 time=272.937 ms
64 bytes from 66.81.203.196: icmp_seq=1 ttl=52 time=317.567 ms
64 bytes from 66.81.203.196: icmp_seq=2 ttl=52 time=137.787 ms
64 bytes from 66.81.203.196: icmp_seq=3 ttl=52 time=137.409 ms
64 bytes from 66.81.203.196: icmp_seq=4 ttl=52 time=451.104 ms
64 bytes from 66.81.203.196: icmp_seq=5 ttl=52 time=138.996 ms
64 bytes from 66.81.203.196: icmp_seq=6 ttl=52 time=155.933 ms
$ ping gsgcorp.com # GS # 평균 RTT=47.523 ms
PING gsgcorp.com (13.209.139.254): 56 data bytes
64 bytes from 13.209.139.254: icmp_seq=0 ttl=110 time=30.281 ms
64 bytes from 13.209.139.254: icmp_seq=1 ttl=110 time=40.400 ms
64 bytes from 13.209.139.254: icmp_seq=2 ttl=110 time=87.502 ms
64 bytes from 13.209.139.254: icmp_seq=3 ttl=110 time=8.373 ms
64 bytes from 13.209.139.254: icmp_seq=4 ttl=110 time=62.710 ms
64 bytes from 13.209.139.254: icmp_seq=5 ttl=110 time=85.802 ms
64 bytes from 13.209.139.254: icmp_seq=6 ttl=110 time=10.718 ms
$ ping newjeans.kr # 뉴진스 # 평균 RTT=12.824 ms
PING newjeans.kr (54.230.176.89): 56 data bytes
64 bytes from 54.230.176.89: icmp_seq=0 ttl=243 time=10.412 ms
64 bytes from 54.230.176.89: icmp_seq=1 ttl=243 time=16.290 ms
64 bytes from 54.230.176.89: icmp_seq=2 ttl=243 time=10.877 ms
64 bytes from 54.230.176.89: icmp_seq=3 ttl=243 time=15.340 ms
64 bytes from 54.230.176.89: icmp_seq=4 ttl=243 time=10.767 ms
64 bytes from 54.230.176.89: icmp_seq=5 ttl=243 time=9.769 ms
=> 각 평균 RTT는 다음과 같다.
=> 전체 평균 RTT는 다음과 같다.
파이프라이닝을 지원하는 Redis Go 클라이언트를 사용하여 파이프라이닝으로 인한 속도 향상을 테스트해보았다.
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var ctx = context.Background()
func bench(descr string, fn func()) {
start := time.Now()
fn()
fmt.Printf("%s %v seconds\n", descr, time.Since(start))
}
func withoutPipelining() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
for i := 0; i < 10000; i++ {
rdb.Ping(ctx)
}
}
func withPipelining() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
pipe := rdb.Pipeline()
for i := 0; i < 10000; i++ {
pipe.Ping(ctx)
}
_, err := pipe.Exec(ctx)
if err != nil {
panic(err)
}
}
func main() {
bench("without pipelining", withoutPipelining)
bench("with pipelining", withPipelining)
}
실행 결과는 다음과 같다.
go run main.go
without pipelining 668.024715ms seconds
with pipelining 9.112195ms seconds
파이프라이닝을 사용하여 전송 속도를 향상시켰다!
Redis 파이프라이닝(Pipelining)은 클라이언트가 모든 명령을 한 번에 보내고, 그 후에 일괄적으로 모든 응답을 받기 때문에 네트워크 오버헤드를 줄이고 처리 속도를 향상시킬 수 있다. 이를 통해 대규모 데이터 처리 및 응용 프로그램 성능을 향상시킬 수 있다. 그러나 응답 순서가 보장되지 않고, 서버의 메모리 사용량이 늘어날 수 있으므로 주의가 필요하다.