Redis 파이프라이닝(Pipelining)

Redis 파이프라이닝(Pipelining)

여러 명령을 한 번에 묶어서 왕복 시간(RTT)을 줄이는 방법을 설명해요. Redis 명령을 배치로 보내면 네트워크 왕복 비용을 크게 아낄 수 있어요.

출처: https://redis.io/docs/latest/develop/using-commands/pipelining/

Request/Response 프로토콜과 왕복 시간(RTT)

Redis는 TCP 서버로, 클라이언트-서버 모델에서 이른바 Request/Response 프로토콜을 사용해요. 즉 하나의 요청은 보통 이런 흐름으로 처리돼요.

  • 클라이언트가 서버에 쿼리를 보내고, 보통 블로킹 방식으로 소켓에서 서버 응답을 읽어요.
  • 서버가 명령을 처리하고 응답을 클라이언트에 되돌려 보내요.

예를 들어 네 개 명령의 응답 흐름은 이렇게 생겼어요.

  • Client: INCR X → Server: 1
  • Client: INCR X → Server: 2
  • Client: INCR X → Server: 3
  • Client: INCR X → Server: 4

클라이언트와 서버는 네트워크 링크로 연결돼요. 이 링크는 루프백 인터페이스처럼 매우 빠를 수도 있고, 인터넷을 거쳐 많은 홉을 지나는 아주 느린 링크일 수도 있어요. 어느 쪽이든 패킷이 클라이언트에서 서버로, 다시 응답을 싣고 서버에서 클라이언트로 이동하는 데 시간이 걸려요.

이 시간을 RTT(Round Trip Time) 라고 불러요. 클라이언트가 연속으로 많은 요청을 처리해야 할 때(예를 들어 같은 리스트에 요소를 많이 추가하거나, DB에 키를 많이 채울 때) 이 값이 성능에 얼마나 영향을 주는지 쉽게 알 수 있어요. 예를 들어 RTT가 250ms라면, 서버가 초당 10만 요청을 처리할 수 있어도 실제로는 초당 최대 네 개 요청밖에 처리하지 못해요.

루프백 인터페이스를 쓰면 RTT가 훨씬 짧아져요. 보통 1ms 미만이지만, 연속으로 쓰기를 많이 해야 한다면 이것도 쌓이면 꽤 커져요. 다행히 이 상황을 개선할 방법이 있어요.

Redis 파이프라이닝

Request/Response 서버는 클라이언트가 이전 응답을 아직 읽지 않았어도 새 요청을 처리하도록 구현할 수 있어요. 이렇게 하면 응답을 기다리지 않고 여러 명령을 서버에 보낸 뒤, 마지막에 응답을 한 번에 읽을 수 있어요.

이걸 파이프라이닝이라고 하고, 수십 년 동안 널리 쓰여 온 기법이에요. 예를 들어 많은 POP3 구현이 이미 이 기능을 지원해서, 서버에서 새 이메일을 다운로드하는 과정을 크게 빨라지게 했어요.

Redis는 초기부터 파이프라이닝을 지원했기 때문에, 어떤 버전을 쓰든 Redis에서 파이프라이닝을 쓸 수 있어요. 원시 netcat 유틸리티를 쓰는 예시는 이렇습니다.

$ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG

이번에는 매 호출마다 RTT 비용을 내지 않고, 세 명령에 대해 한 번만 내요.

명시하자면, 파이프라이닝을 쓸 때 처음 예시의 순서는 이렇게 바뀌어요.

  • Client: INCR X, INCR X, INCR X, INCR X
  • Server: 1, 2, 3, 4

중요한 점: 클라이언트가 파이프라이닝으로 명령을 보내는 동안, 서버는 응답을 메모리로 큐에 쌓아 둬야 해요. 그래서 파이프라이닝으로 명령을 많이 보내야 한다면, 적당한 수(예: 1만 개)씩 배치로 보내고 응답을 읽은 뒤 다시 1만 개를 보내는 식으로 하는 게 좋아요. 속도는 거의 같지만, 추가 메모리는 이 1만 개 명령의 응답을 큐에 쌓는 만큼만 쓰여요.

단순히 RTT 문제만은 아니에요

파이프라이닝은 왕복 시간에 따른 지연 비용을 줄이는 방법일 뿐 아니라, 주어진 Redis 서버에서 초당 처리할 수 있는 연산 수 자체를 크게 늘려 줘요. 파이프라이닝을 쓰지 않으면 각 명령을 서비스하는 것은 데이터 구조 접근과 응답 생성 측면에서는 매우 저렴하지만, 소켓 I/O 측면에서는 매우 비싸요. 여기엔 read()write() 시스템 콜 호출, 즉 유저 영역에서 커널 영역으로의 전환이 포함되고, 이 컨텍스트 스위칭이 큰 속도 저하를 일으켜요.

파이프라이닝을 쓰면 많은 명령이 하나의 read() 시스템 콜로 읽히고, 여러 응답이 하나의 write() 시스템 콜로 전달돼요. 그 결과 초당 처리하는 총 쿼리 수는 파이프라인이 길어질수록 처음에는 거의 선형으로 증가하다가, 결국 파이프라이닝을 쓰지 않았을 때의 10배에 도달해요.

실제 코드 예시

다음 벤치마크에서는 파이프라이닝을 지원하는 Redis Ruby 클라이언트로 파이프라이닝의 속도 향상을 확인해 볼게요.

require 'rubygems'
require 'redis'

def bench(descr)
  start = Time.now
  yield
  puts "#{descr} #{Time.now - start} seconds"
end

def without_pipelining
  r = Redis.new
  10_000.times do
    r.ping
  end
end

def with_pipelining
  r = Redis.new
  r.pipelined do |rp|
    10_000.times do
      rp.ping
    end
  end
end

bench('without pipelining') do
  without_pipelining
end
bench('with pipelining') do
  with_pipelining
end

이 간단한 스크립트를 루프백 인터페이스에서 돌리면 다음과 같은 결과가 나와요(루프백이면 RTT가 이미 낮아서 개선 폭이 가장 작은 환경이에요).

without pipelining 1.185238 seconds
with pipelining 0.250783 seconds

보시다시피 파이프라이닝을 쓰면 전송 성능이 다섯 배 정도 향상돼요.

파이프라이닝 vs 스크립팅(Scripting)

Redis 2.6부터 제공되는 Redis scripting으로는, 서버 쪽에서 많은 작업을 수행하는 스크립트를 이용해 파이프라이닝의 여러 사용 사례를 더 효율적으로 해결할 수 있어요. 스크립팅의 큰 장점은 최소한의 지연으로 읽기와 쓰기를 모두 처리할 수 있다는 점이에요. read, compute, write 같은 연산을 매우 빠르게 만들 수 있어요. (파이프라이닝은 읽기 명령의 응답을 받아야 쓰기 명령을 호출할 수 있으므로, 이런 시나리오에서는 도움이 되지 않아요.)

때때로 애플리케이션이 파이프라인 안에서 EVAL이나 EVALSHA 명령을 보내고 싶을 수도 있어요. 이것은 완전히 가능하며, Redis가 SCRIPT LOAD 명령으로 명시적으로 지원해요. (이 명령은 EVALSHA가 실패할 위험 없이 호출될 수 있게 보장해줘요.)

부록: 루프백 인터페이스에서도 바쁜 루프가 왜 느릴까요?

이 페이지에서 배경을 다 다뤘는데도, 서버와 클라이언트가 같은 물리 머신에서 실행되는데 다음과 같은 Redis 벤치마크(의사 코드)가 루프백 인터페이스에서조차 느린 이유가 궁금할 수 있어요.

FOR-ONE-SECOND:
    Redis.SET("foo","bar")
END

Redis 프로세스와 벤치마크가 같은 상자에서 도는 데, 단순히 메시지를 메모리 안에서 한 곳에서 다른 곳으로 복사하는 것일 뿐이라면 실제 지연이나 네트워킹이 없지 않으냐고요? 그 이유는 시스템의 프로세스가 항상 실행 중인 건 아니고, 커널 스케줄러가 프로세스를 실행시키기 때문이에요. 그래서 벤치마크가 실행되면 Redis 서버로부터 응답을 읽고 새 명령을 씁니다. 명령은 루프백 인터페이스 버퍼에 들어가지만, 서버가 읽으려면 커널이 (시스템 콜로 블로킹된) 서버 프로세스를 스케줄링해 실행해야 해요. 그래서 실제로는 커널 스케줄러 동작 때문에 루프백 인터페이스도 네트워크와 비슷한 지연을 수반해요.

기본적으로 네트워크 서버의 성능을 측정할 때 바쁜 루프 벤치마크는 할 수 있는 가장 어리석은 일이에요. 이렇게 벤치마킹하지 않는 게 현명해요.

더 알아보기 (Learn more)