Ruby Magic 이전 호에서는 여러 프로세스를 활용해 채팅 시스템을 구현하는 방법을 소개했습니다. 이번에는 같은 기능을 멀티스레딩(Multi-threading)으로 구현하는 방법을 알아보겠습니다.
빠른 복습
기본 설정에 대한 자세한 설명이 필요하다면 이전 글을 먼저 참고하세요. 간단히 요약하자면, 우리가 만들 채팅 시스템은 다음과 같은 구조로 되어 있습니다.
클라이언트는 이전에 사용했던 것과 동일합니다:
# client.rb
# $ ruby client.rb
require 'socket'
client = TCPSocket.open(ARGV[0], 2000)
Thread.new do
while line = client.gets
puts line.chop
end
end
while input = STDIN.gets.chomp
client.puts input
end
서버의 기본 설정도 마찬가지입니다:
# server_threads.rb
# $ ruby server_threads.rb
require 'socket'
puts 'Starting server on port 2000'
server = TCPServer.open(2000)
이 글의 예제에 사용된 전체 소스 코드는 GitHub에서 확인할 수 있으니, 직접 내려받아 실험해 보셔도 좋습니다.
멀티스레드 채팅 서버
이제 멀티 프로세스 구현과 달라지는 부분을 살펴보겠습니다. 멀티스레딩을 사용하면 단 하나의 Ruby 프로세스만으로 여러 작업을 동시에 처리할 수 있습니다. 이를 위해 실제 작업을 수행하는 여러 스레드를 생성하게 됩니다.
스레드(Thread)
스레드는 하나의 프로세스 내부에서 코드를 독립적으로 실행하는 실행 단위입니다. 하나의 프로세스 안에 여러 스레드가 함께 존재할 수 있으며, 이들은 서로 메모리를 공유합니다.
수신되는 채팅 메시지를 저장할 공간이 필요합니다. 여기서는 단순한 Array를 사용하지만, 여러 스레드가 동시에 메시지 목록을 수정하는 상황을 방지하기 위해 Mutex도 함께 사용합니다. (Mutex의 동작 방식은 잠시 후 자세히 설명하겠습니다.)
mutex = Mutex.new
messages = []
다음으로 채팅 클라이언트의 연결 요청을 계속해서 받아들이는 루프를 시작합니다. 연결이 수립되면, 해당 클라이언트와 주고받는 메시지를 처리할 전용 스레드를 새로 생성합니다.
Thread.new 호출은 server.accept가 결과를 반환할 때까지 대기한 뒤, 새로 생성된 스레드에서 인자로 넘긴 블록을 실행합니다. 스레드 내부의 코드는 먼저 클라이언트가 보낸 첫 번째 줄을 읽어 닉네임으로 저장하고, 이후 메시지를 주고받는 작업을 시작합니다.
loop do
Thread.new(server.accept) do |socket|
nickname = read_line_from(socket)
# Send incoming message (coming up)
# Read incoming messages (coming up)
end
end
뮤텍스(Mutex)
뮤텍스는 여러 스레드가 배열처럼 공유된 자원을 안전하게 사용할 수 있도록 조율해 주는 객체입니다. 스레드는 자원에 대한 접근이 필요하다고 표시할 수 있으며, 그동안 다른 스레드는 해당 공유 자원에 접근할 수 없습니다.
서버는 소켓으로부터 들어오는 메시지를 읽습니다. synchronize를 사용해 메시지 저장소에 대한 잠금(lock)을 획득하면, 다른 스레드의 간섭 없이 안전하게 메시지를 Array에 추가할 수 있습니다.
# Read incoming messages
while incoming = read_line_from(socket)
mutex.synchronize do
messages.push(
:time => Time.now,
:nickname => nickname,
:text => incoming
)
end
end
마지막으로, 서버가 수신한 모든 새 메시지를 클라이언트에게 전달하기 위해 무한 루프를 도는 Thread를 하나 더 생성합니다. 역시 잠금을 획득해 다른 스레드가 데이터를 건드리지 않도록 보장합니다. 한 번의 루프가 끝날 때마다 잠시 대기(sleep)한 후 다음 작업을 계속 진행합니다.
# Send incoming message
Thread.new do
sent_until = Time.now
loop do
messages_to_send = mutex.synchronize do
get_messages_to_send(nickname, messages, sent_until).tap do
sent_until = Time.now
end
end
messages_to_send.each do |message|
socket.puts "#{message[:nickname]}: #{message[:text]}"
end
sleep 0.2
end
end
글로벌 인터프리터 락(GIL)
"Ruby는 GIL(Global Interpreter Lock) 때문에 '진짜' 스레딩을 할 수 없다"는 이야기를 들어보셨을 겁니다. 이는 부분적으로만 사실입니다. GIL은 모든 Ruby 코드의 실행을 감싸는 잠금 장치로, 하나의 Ruby 프로세스가 여러 CPU를 동시에 사용하는 것을 막습니다. 하지만 IO 연산(예: 이 글에서 다룬 네트워크 연결)은 GIL 밖에서 동작하기 때문에, 이런 경우에는 충분히 쓸만한 수준의 동시성을 확보할 수 있습니다.
마무리
이렇게 해서 연결당 하나의 스레드를 할당하는 방식으로, 단일 프로세스 안에서 동작하는 채팅 서버를 완성했습니다. 멀티 프로세스 구현에 비해 훨씬 적은 시스템 리소스를 사용한다는 것이 가장 큰 장점입니다. 코드의 세부 사항을 확인하거나 직접 실행해 보고 싶다면 예제 코드를 참고하세요.
이 시리즈의 마지막 편에서는 단일 스레드와 이벤트 루프(event loop)를 사용해 같은 채팅 서버를 구현해 보겠습니다. 이론상으로는 스레드 방식보다도 더 적은 리소스를 사용할 수 있습니다!