웹 서버와 HTTP는 어려워 보일 수 있습니다. 브라우저는 어떻게 요청을 구성하고, 응답은 어떻게 사용자에게 전달될까요? 이번 Ruby Magic 에피소드에서는 단 30줄의 코드로 Ruby HTTP 서버를 만드는 방법을 알아봅니다. 완성된 서버는 HTTP GET 요청을 처리하며, Rack 앱을 서비스하는 데 활용할 수 있습니다.
HTTP와 TCP는 어떻게 함께 작동할까
TCP는 서버와 클라이언트가 데이터를 교환하는 방식을 정의하는 전송 프로토콜입니다.
HTTP는 웹 서버가 HTTP 클라이언트나 웹 브라우저와 데이터를 교환하는 방식을 구체적으로 정의하는 요청-응답 프로토콜입니다. HTTP는 일반적으로 TCP를 전송 프로토콜로 사용합니다. 본질적으로 HTTP 서버란 "HTTP를 말하는" TCP 서버라고 할 수 있습니다.
# tcp_server.rb
require 'socket'
server = TCPServer.new 5678
while session = server.accept
session.puts "Hello world! The time is #{Time.now}"
session.close
end이 TCP 서버 예제에서 서버는 포트 5678에 바인딩된 후 클라이언트의 연결을 기다립니다. 연결이 이루어지면 클라이언트에게 메시지를 보낸 뒤 연결을 닫습니다. 첫 번째 클라이언트와의 통신이 끝나면, 서버는 다시 새로운 클라이언트가 접속해 메시지를 받기를 기다립니다.
# tcp_client.rb
require 'socket'
server = TCPSocket.new 'localhost', 5678
while line = server.gets
puts line
end
server.close서버에 연결하려면 TCP 클라이언트가 필요합니다. 이 예제 클라이언트는 동일한 포트(5678)에 연결한 뒤 server.gets를 사용해 서버로부터 데이터를 수신하고 출력합니다. 더 이상 데이터를 받지 못하면 서버와의 연결을 닫고 프로그램이 종료됩니다.
서버를 실행한 상태($ ruby tcp_server.rb)에서 별도의 탭에서 클라이언트를 시작하면 서버의 메시지를 받을 수 있습니다.
$ ruby tcp_client.rb
Hello world! The time is 2016-11-23 15:17:11 +0100
$
약간의 상상력을 발휘하면, 이 TCP 서버와 클라이언트는 웹 서버와 브라우저처럼 작동합니다. 클라이언트가 요청을 보내고, 서버가 응답하며, 연결이 닫히는 방식입니다. 이것이 바로 요청-응답 패턴의 작동 원리로, HTTP 서버를 만드는 데 필요한 핵심 개념입니다.
본격적인 구현에 들어가기 전에, HTTP 요청과 응답이 실제로 어떤 모습인지 살펴보겠습니다.
기본적인 HTTP GET 요청
가장 기본적인 HTTP GET 요청은 추가 헤더나 요청 본문 없이 요청 라인(request-line) 하나로만 구성됩니다.
GET / HTTP/1.1\r\n
요청 라인(Request-Line)은 네 부분으로 이루어집니다:
- 메서드 토큰 (이 예제에서는
GET) - 요청 URI (
/) - 프로토콜 버전 (
HTTP/1.1) - 줄의 끝을 나타내는 CRLF (캐리지 리턴
\r+ 라인 피드\n)
서버는 다음과 같은 형태의 HTTP 응답으로 답변합니다:
HTTP/1.1 200\r\nContent-Type: text/html\r\n\r\n\Hello world!
이 응답은 다음 요소들로 구성됩니다:
- 상태 라인: 프로토콜 버전("HTTP/1.1"), 공백, 응답 상태 코드("200") 순으로 구성되며 CRLF(
\r\n)로 종료됩니다 - 선택적 헤더 라인: 이 예제에서는 헤더가 하나뿐("Content-Type: text/html")이지만, 여러 개가 올 수도 있습니다(CRLF로 구분:
\r\n) - 줄바꿈(또는 이중 CRLF): 상태 라인과 헤더를 본문과 구분합니다 (
\r\n\r\n) - 본문: "Hello world!"
미니멀한 Ruby HTTP 서버
이론은 충분합니다. 이제 Ruby에서 TCP 서버를 만드는 방법과 HTTP 요청·응답의 구조를 알았으니, 미니멀한 HTTP 서버를 직접 만들어 보겠습니다. 아래 웹 서버가 앞서 살펴본 TCP 서버와 거의 비슷하다는 점을 눈치채셨다면 정확히 보신 겁니다. 전반적인 구조는 동일하며, 메시지 형식을 HTTP 프로토콜에 맞게 지정할 뿐입니다. 또한 브라우저를 사용해 요청을 보내고 응답을 파싱하므로, 이번에는 클라이언트를 따로 구현할 필요가 없습니다.
# http_server.rb
require 'socket'
server = TCPServer.new 5678
while session = server.accept
request = session.gets
puts request
session.print "HTTP/1.1 200\r\n" # 1
session.print "Content-Type: text/html\r\n" # 2
session.print "\r\n" # 3
session.print "Hello world! The time is #{Time.now}" #4
session.close
end서버가 요청을 받으면, 이전과 마찬가지로 session.print를 사용해 클라이언트에게 메시지를 되돌려 보냅니다. 단순히 메시지만 보내는 것이 아니라, 응답 앞에 상태 라인, 헤더, 줄바꿈을 붙입니다:
- 상태 라인 (
HTTP/1.1 200\r\n): 브라우저에게 HTTP 버전이 1.1이고 응답 코드가 "200"임을 알립니다 - 헤더: 응답이 text/html 콘텐츠 타입임을 나타냅니다 (
Content-Type: text/html\r\n) - 줄바꿈 (
\r\n) - 본문: "Hello world! …"
이전과 마찬가지로 메시지를 보낸 후 연결을 닫습니다. 아직 요청 내용을 활용하지는 않으므로, 우선 콘솔에 출력만 하고 넘어갑니다.
서버를 시작하고 브라우저에서 https://localhost:5678을 열면, 앞서 TCP 클라이언트에서 받았던 것처럼 현재 시간이 포함된 "Hello world! …" 문구를 확인할 수 있습니다. 🎉
Rack 앱 서비스하기
지금까지 우리 서버는 모든 요청에 대해 단일 응답만 반환했습니다. 조금 더 유용하게 만들려면 다양한 응답을 추가할 수 있습니다. 이러한 응답을 서버에 직접 추가하는 대신, Rack 앱을 활용해 보겠습니다. 우리 서버는 HTTP 요청을 파싱하여 Rack 앱에 전달하고, Rack 앱은 서버가 클라이언트에게 되돌려 보낼 응답을 반환합니다.
Rack은 Ruby를 지원하는 웹 서버와 Rails, Sinatra 같은 대부분의 Ruby 웹 프레임워크 사이를 잇는 인터페이스입니다. 가장 단순한 형태로, Rack 앱은 call에 응답하는 객체로서 세 가지 항목을 담은 배열, 즉 "트리플렛(triplet)"을 반환합니다: HTTP 응답 코드, HTTP 헤더 해시, 그리고 본문입니다.
app = Proc.new do |env|
['200', {'Content-Type' => 'text/html'}, ["Hello world! The time is #{Time.now}"]]
end이 예제에서 응답 코드는 "200"이고, 헤더를 통해 "text/html"을 콘텐츠 타입으로 전달하며, 본문은 문자열 하나를 담은 배열입니다.
서버가 이 앱의 응답을 서비스하려면, 반환된 트리플렛을 HTTP 응답 문자열로 변환해야 합니다. 이전처럼 항상 정적 응답을 반환하는 대신, 이제는 Rack 앱이 반환한 트리플렛으로부터 응답을 동적으로 구성해야 합니다.
# http_server.rb
require 'socket'
app = Proc.new do
['200', {'Content-Type' => 'text/html'}, ["Hello world! The time is #{Time.now}"]]
end
server = TCPServer.new 5678
while session = server.accept
request = session.gets
puts request
# 1
status, headers, body = app.call({})
# 2
session.print "HTTP/1.1 #{status}\r\n"
# 3
headers.each do |key, value|
session.print "#{key}: #{value}\r\n"
end
# 4
session.print "\r\n"
# 5
body.each do |part|
session.print part
end
session.close
endRack 앱에서 받은 응답을 서비스하기 위해 서버에 다음과 같은 변경 사항을 적용했습니다:
app.call이 반환한 트리플렛에서 상태 코드, 헤더, 본문을 추출합니다- 상태 코드를 사용해 상태 라인을 구성합니다
- 헤더를 순회하며 해시의 각 키-값 쌍에 해당하는 헤더 라인을 추가합니다
- 상태 라인과 헤더를 본문과 구분하는 줄바꿈을 출력합니다
- 본문을 순회하며 각 부분을 출력합니다. 본문 배열에 항목이 하나뿐이므로, 연결을 닫기 전에 "Hello world" 메시지가 세션에 출력됩니다
요청 읽기
지금까지 우리 서버는 request 변수를 무시해 왔습니다. Rack 앱이 항상 동일한 응답을 반환했기 때문에 굳이 필요하지 않았던 것입니다.
Rack::Lobster는 Rack에 기본 포함된 예제 앱으로, 제대로 작동하려면 요청 URL 매개변수를 사용합니다. 이제부터는 이전에 앱으로 사용했던 Proc 대신 이것을 테스트 앱으로 활용하겠습니다.
# http_server.rb
require 'socket'
require 'rack'
require 'rack/lobster'
app = Rack::Lobster.new
server = TCPServer.new 5678
while session = server.accept
# ...브라우저를 열면 이제 지루한 문자열 대신 랍스터가 표시됩니다. Lobstericious!
"flip!"과 "crash!" 링크는 각각 /?flip=left와 /?flip=crash로 연결됩니다. 하지만 링크를 클릭해도 랍스터가 뒤집히지 않고, 아무것도 크래시되지 않습니다. 그 이유는 우리 서버가 아직 쿼리 문자열을 처리하지 않기 때문입니다. 이전에 무시했던 request 변수를 기억하시나요? 서버 로그를 살펴보면 각 페이지의 요청 문자열을 확인할 수 있습니다.
GET / HTTP/1.1
GET /?flip=left HTTP/1.1
GET /?flip=crash HTTP/1.1
HTTP 요청 문자열에는 요청 메서드("GET"), 요청 경로(/, /?flip=left, /?flip=crash), 그리고 HTTP 버전이 포함되어 있습니다. 이 정보를 활용하면 무엇을 서비스해야 할지 판단할 수 있습니다.
# http_server.rb
require 'socket'
require 'rack'
require 'rack/lobster'
app = Rack::Lobster.new
server = TCPServer.new 5678
while session = server.accept
request = session.gets
puts request
# 1
method, full_path = request.split(' ')
# 2
path, query = full_path.split('?')
# 3
status, headers, body = app.call({
'REQUEST_METHOD' => method,
'PATH_INFO' => path,
'QUERY_STRING' => query
})
session.print "HTTP/1.1 #{status}\r\n"
headers.each do |key, value|
session.print "#{key}: #{value}\r\n"
end
session.print "\r\n"
body.each do |part|
session.print part
end
session.close
end요청을 파싱하고 요청 매개변수를 Rack 앱에 전달하기 위해, 요청 문자열을 분할한 뒤 Rack 앱에 넘겨줍니다:
- 요청 문자열을 메서드와 전체 경로로 분할합니다
- 전체 경로를 경로와 쿼리로 분할합니다
- 이 값들을 Rack 환경(environment) 해시에 담아 앱에 전달합니다
예를 들어, GET /?flip=left HTTP/1.1\r\n과 같은 요청은 다음과 같은 형태로 앱에 전달됩니다:
{
'REQUEST_METHOD' => 'GET',
'PATH_INFO' => '/',
'QUERY_STRING' => '?flip=left'
}서버를 재시작하고 https://localhost:5678에 접속한 후 "flip!" 링크를 클릭하면 이제 랍스터가 뒤집히고, "crash!" 링크를 클릭하면 웹 서버가 크래시됩니다.
방금 우리는 HTTP 서버 구현이라는 방대한 주제의 극히 일부만 다루었고, 서버 코드 역시 단 30줄에 불과하지만 핵심 아이디어는 충분히 설명됩니다. 이 서버는 GET 요청을 받아들이고, 요청의 속성을 Rack 앱에 전달하며, 브라우저에 응답을 되돌려 보냅니다. 요청 스트리밍이나 POST 요청 같은 기능은 처리하지 못하지만, 이론상으로는 다른 Rack 앱을 서비스하는 데에도 활용할 수 있습니다.
여기까지 Ruby로 HTTP 서버를 만드는 짧은 여정이었습니다. 이 서버를 직접 가지고 실험해 보고 싶으시다면 위 코드를 활용해 보세요. 더 궁금한 점이나 구체적인 질문이 있다면 @AppSignal로 알려주세요.
이 글이 유익했다면 Ruby Magic 뉴스레터를 구독해 보세요. (대략) 매월 한 번씩 배달되는 Ruby의 특별한 한 입(po(r)tion)입니다.