TypeScript로의 첫 시도에서 출발해 Gleam 언어로 순수 Erlang 타깃의 웹 서버 ewe를 만드는 과정을 소개한다. TCP/HTTP 파싱, keep-alive, 스트리밍 요청 바디, WebSocket과 SSE, RFC 준수, 성능 측정, Date 헤더와 시계 액터 등 구현 세부와 배운 점을 담았다.
2025년 10월 19일
#gleam
이건 제 인생 첫 글입니다. 영어가 유창하지도 않고, 때로는 제 생각을 명확하게 표현하는 데 어려움을 겪기도 해서 글이 다소 산만하게 느껴질 수 있습니다. 미리 양해 부탁드립니다. 또 하나, 제 글은 AI가 대충 써낸 글이 아닙니다. 다른 사람들의 작업물로 학습된 모델에 기대지 않고, 제 방식대로 제 생각을 직접 기록하고 싶기 때문에 AI 사용은 가능한 한 줄일 생각입니다.
시간이 꽤 많은 편인데, 지난 1년 동안 그 소중한 시간을 대부분 미루기에 쓰고 있다는 걸 깨달았습니다. 유튜브 같은 엔터테인먼트 콘텐츠를 보거나 틱톡을 그냥 훑어보는 식이었죠. 어느 날, 이렇게 학생으로서 마지막 시기를 보내고 싶지는 않다는 생각이 들었습니다. 그래서 관심 있던 분야, 즉 웹 개발에 훨씬 더 많은 시간을 쏟기로 했습니다. 대학 시절 내내 React + Next.js + _TypeScript_로 간단한 프로젝트들을 만들었는데, 사실 진짜 즐겁지 않은 걸 붙잡고 시간을 낭비하는 느낌이었습니다. 그때 프런트엔드에서 백엔드 개발로 커리어 목표를 바꾸기로 결심했죠. 서버 사이드라는 게 예전부터 흥미롭긴 했지만 더 어려운 분야라고 생각했거든요. 서버 사이드에서 사람들이 처음 시도해서 배포하는 것들, 예를 들어 API 같은 걸 보면서 “웹 프레임워크는 대체 어떻게 동작하지?”라는 의문이 들었습니다. 그래서 첫 번째 작은 목표를 세웠습니다. 웹 개발자가 쓸 수 있는 제 도구를 만들어 보자고요.
그 당시 저는 _TypeScript_를 꽤 좋아했습니다. 그래서 처음 떠올린 건 TypeScript로 웹 프레임워크를 만드는 것이었죠. 런타임으로 _Deno_를 선택하고 sakura 개발을 시작했습니다. 이 프로젝트의 아이디어는 웹 애플리케이션을 나무에 비유하는 다소 기발한 철학 위에 세웠습니다. 라우터는 branch, 각 요청의 컨텍스트 격인 것을 seed 같은 식으로 멋들어진 이름을 붙였죠. 라우터는 파이프로 쓰는 걸 의도했습니다. 각 라우터 메서드는 같은 라우터 인스턴스를 반환해 다시 파이프할 수 있고, 미들웨어는 다음에 파이프되는 라우트들에만 적용되고 이전 라우트에는 적용되지 않도록 했습니다. _sakura_를 사용한 코드는 대략 이런 식이었습니다:
import { bloom, fall, pluck, sakura } from "@vsh/sakura"
// 서버가 시작된 시각을 정의합니다
const uptime = Date.now()
// Seed는 매 요청마다 생성됩니다. 여기에 유틸리티를 마음대로 담을 수 있습니다.
const { branch, seed } = sakura((req, cookies) => ({
req,
cookies,
runtime: Date.now() - uptime,
}))
// /ping, /runtime, /secret 엔드포인트가 있는 브랜치를 생성합니다
const app = branch()
.get("/ping", () => fall(200, { message: "pong" }))
.get("/runtime", ({ seed: { runtime } }) => fall(200, { runtime }))
.with((seed) => {
// secret 쿠키를 가져옵니다
const { secret } = seed.cookies.get<"secret">()
if (!secret) {
// 변환을 중단하고 응답을 반환합니다
throw pluck(400, {
message: "secret is not provided.",
})
}
// 새로운 seed를 반환합니다
return {
...seed,
secret,
}
})
.get("/secret", ({ seed: { secret } }) => fall(200, { secret }))
// 서버를 시작합니다
bloom({
// Seed 생성기
seed,
// 실행할 브랜치
branch: app,
// 에러 시 실행
error: () => fall(500, { message: "try again later" }),
// 경로가 없을 때 실행
unknown: () => fall(404, { message: "unknown endpoint" }),
// Content-Type이 application/json이 아닐 때 실행
unsupported: () => fall(415, { message: "body must be json" }),
port: 4040,
// 각 요청을 로깅
logger: true,
})
간단한 HTTP 웹 프레임워크로서는 꽤 괜찮게 동작했지만, 만족스럽지는 않았습니다. 그리고 수백 가지 더 나은 옵션 중에서 누가 _sakura_를 고를까 싶었죠. 그래서 이 프로젝트를 포기하고 한동안 잠수를 탔습니다. 몇 달이 지나면서 언어 취향이 바뀌었고 _TypeScript_는 한동안 손도 대지 않았습니다. 하지만 이력서에 넣을 만한 흥미로운 프로젝트는 여전히 없었죠… 최근까지는요!
_Go_로 마이크로서비스와 gRPC를 쓰는 간단한 인프라를 만들려다 보니, 미래의 제 역할에 필요한 초보 수준의 지식조차 부족하다는 걸 깨달았습니다. 걱정스러웠죠. 좋은 개발자가 되려면 컴퓨터가 내부적으로 어떻게 동작하는지, 그리고 장치들이 네트워크를 통해 어떻게 통신하는지 많이 알아야 한다고 생각합니다. 그럼 그 주제를 공부하는 가장 좋은 방법은 뭐가 있을까요? 관련된 멋진 프로젝트를 직접 만들어 보는 거죠! 그래서 다시 웹 개발 도구를 만들기로 했습니다. 이번에는 더 저수준인 웹 서버입니다.
언어 취향이 바뀌었다고 했죠? _Go_를 시도하는 것 외에도, 저는 Erlang 생태계의 일부인 정적 타입 함수형 언어 Gleam을 적극적으로 사용해 왔습니다. 첫인상이 “와, 이렇게 간단한데 뭐든 만들 수 있네!”였습니다. 물론 아직 젊고 다른 기술만큼 인기 있지도 않으며, 인턴 수준의 학생인 제 포지션으로 Gleam 관련 구직을 하기는 어렵지만, 저는 프로젝트에 이 언어를 쓰기로 마음먹었고, 아니면 최소한 Discord 커뮤니티를 자주 들여다봤습니다. 그곳에는 사람들이 이 언어로 만든 멋진 프로젝트를 많이 올리고 있었거든요. 저도 뭔가를 올리고 싶어졌습니다.
그래서 일석이조로 가기로 했습니다. _Gleam_으로 웹 서버를 만들면서, 진행 상황을 Discord 커뮤니티에 공유하는 거죠. 그렇게 해서 제가 시작한 것이 바로 보송보송한 Gleam 웹 서버 ewe입니다. 이 언어가 1.0을 낸 게 불과 1년 전이라 Gleam 생태계는 아직 강하지 않습니다. 그래서 웹 서버를 포함해 다양한 주제의 패키지를 개발할 기회가 많습니다. 지금으로선(혹시 제가 틀렸다면 연락 주세요) Erlang 타깃을 위한 순수 Gleam 웹 서버는 mist 하나뿐입니다. 이러면 모두가 하나의 패키지에 의존하게 되고, 새로운 기능으로 더 빠르고 강하게 발전하도록 이끄는 건강한 경쟁이 없습니다. 저는 이것도 바꾸고 싶었습니다. 친화적인 경쟁자를 만들어 웹 서버 쪽 개발을 더 활발하게 만드는 겁니다.
이 질문에 답하려면, 클라이언트와 서버 같은 장치들이 어떻게 통신하는지 이해해야 합니다. 가장 흔한 방식은 소켓을 통한 통신입니다. 소켓은 두 프로그램이 네트워크를 통해 통신하는 단순한 채널입니다. 서버 프로그램은 호스트 컴퓨터의 특정 진입점인 포트에서 소켓을 만들고, 클라이언트가 연결을 요청할 때까지 기다립니다. 연결이 성립되면 서버는 소켓에 대한 입력/출력 스트림을 만들고 메시지를 보내고 받기 시작합니다. 클라이언트도 소켓을 만들고, 서비스가 존재하는 주소와 포트로 서버에 연결을 시도합니다. 서버와 마찬가지로 소켓에 대한 입력/출력 스트림을 생성합니다. 통신이 끝나면 연결을 닫는 책임은 보통 클라이언트에게 있지만, 필요하면 서버가 닫을 수도 있습니다. 이 소켓 위에 표준화된 규칙 집합, 즉 TCP(Transmission Control Protocol)와 UDP(User Datagram Protocol) 같은 전송 프로토콜이 만들어졌습니다. 둘 사이에는 큰 차이가 있고, 여기서는 웹과 이메일 시스템 등에 필수적인 _TCP_를 살펴보겠습니다.
_TCP_는 연결 지향 프로토콜입니다. 즉, 데이터를 전송하기 전에 연결을 성립해야 하고, 모든 데이터 전송을 마친 후에는 연결을 닫아야 합니다. 목적지까지 데이터가 도달함을 보장하기 때문에 신뢰성이 있습니다. 연결은 3-way 핸드셰이크로 성립됩니다. 이 핸드셰이크 동안 클라이언트와 서버는 초기 시퀀스 번호를 교환하고 연결 성립을 확정합니다. 그다음에는 원하는 대로 데이터 패킷을 보낼 준비가 됩니다. _TCP_는 요청과 응답의 구조를 정의하는 HTTP 같은 다른 인기 프로토콜에 의해 사용됩니다. _HTTP_는 오늘날 웹 생태계 전체를 움직이는 핵심 톱니바퀴입니다.
웹 서버가 무엇이냐는 질문으로 돌아오면, 웹 서버는 서버 장치에서 실행되며 HTTP 또는 _HTTPS_를 사용해 클라이언트의 요청을 처리하는 프로그램입니다. 이걸 염두에 두고, _ewe_가 어떻게 0에서 시작해 아마도 프로덕션 준비까지 나아갔는지 이야기해 보겠습니다.
먼저 _TCP_를 어떻게 다룰지부터 결정했습니다. glisten 패키지가 소켓 acceptor 풀 위에 슈퍼바이저를 제공하므로 최선의 선택이었습니다. TLS(Transport Layer Security)도 지원해 _HTTPS_도 다룰 수 있습니다. 이렇게 TCP 핸드셰이크와 연결 자체는 수월하게 처리되니, 저는 흥미로운 웹 서버 기능들에 집중할 수 있었습니다.
첫 과제는 수신 패킷을 파싱하는 일이었습니다. 저는 수신한 원시 바이트를 HTTP 요청의 구성 요소(요청 라인, 헤더, 바디(청크 전송 인코딩 포함))로 변환하는 파서를 직접 구현했습니다. 초기 컨셉은 대략 이랬습니다:
import gleam/bytes_tree
import gleam/option.{None}
import gleam/otp/actor
import gleam/otp/static_supervisor as supervisor
import glisten
import internal/parser
pub fn start(
port port: Int,
) -> Result(actor.Started(supervisor.Supervisor), actor.StartError) {
glisten.new(
// 각 연결마다 파서를 초기화합니다
fn(_conn) { #(parser.new_parser(), None) },
// 도착하는 각 패킷을 처리합니다
fn(state, msg, conn) {
let assert glisten.Packet(msg) = msg
// 새 데이터를 버퍼에 추가합니다
let parser =
parser.Parser(..state, buffer: <<state.buffer:bits, msg:bits>>)
// 현재까지 가진 데이터로 파싱을 시도합니다
case parser.parse_request(parser) {
Ok(_request) -> {
// 성공! 일단은 더미 응답을 보냅니다
let response = <<
"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world!",
>>
let _ =
response
|> bytes_tree.from_bit_array()
|> glisten.send(conn, _)
// 다음 요청을 위해 파서를 초기화합니다
glisten.continue(parser.new_parser())
}
Error(error) -> {
case error {
// 아직 데이터가 충분치 않습니다. 누적을 계속합니다
parser.Incomplete(parser) -> glisten.continue(parser)
// 잘못된 HTTP 요청이므로 거절합니다
parser.Invalid -> {
let _ =
glisten.send(
conn,
<<"HTTP/1.1 400 Bad Request\r\n\r\n">>
|> bytes_tree.from_bit_array,
)
glisten.stop()
}
// 요청 본문이 너무 큽니다. 거절합니다
parser.TooLarge -> {
echo "Too large"
glisten.stop()
}
_ -> glisten.stop()
}
}
}
},
)
|> glisten.bind("0.0.0.0")
|> glisten.start(port)
}
중요한 점 한 가지: 패킷은 조각나서 도착할 수 있습니다. 한 패킷에는
GET / H까지 오고, 다음 패킷에는TTP/1.1\r\n…이 올 수도 있죠. 파서는 완전한 HTTP 요청 파트를 파싱할 수 있을 만큼 데이터가 모일 때까지 이 조각들을 버퍼에 누적해야 합니다.
이 파서는 기본 테스트 케이스에서는 동작했지만 곧 문제가 있다는 걸 깨달았습니다. 예를 들어, 저는 HTTP 요청 타입을 직접 구현하고 있었는데, HTTP 관련 Gleam 프로젝트에서 모두 사용하는 공식 패키지 gleam_http를 따르지 않는 건 모범 사례가 아니었습니다. 또 이 파서 로직에서는 요청 본문 전체를 메모리에 올립니다. 바디 스트림이 아주 크면 어떻게 하죠? 또는 요청 핸들러가 바디를 전혀 읽을 필요가 없다면요? 그러면 파서가 단일 요청에 너무 많은 시간을 낭비할 수 있습니다. 커뮤니티의 빠른 브레인스토밍과 제안 덕분에 더 나은 접근을 배웠고, 전면 리팩터링을 진행했습니다.
_Gleam_이 좋은 점 중 하나는 런타임의 외부 함수와 타입을 쉽게 사용할 수 있다는 겁니다. Gleam은 _Erlang_으로 컴파일되기 때문에, 그 검증된 생태계를 곧장 활용할 수 있죠. 처음에는 제 손으로 HTTP 파서를 썼는데(느리고 버그도 있었습니다), 그러다 erlang:decode_packet이라는, 수십 년 동안 HTTP 파싱에 쓰여 온 함수를 발견했습니다. 믿을 수 없을 만큼 효율적이고, 제가 고려조차 못했던 프로토콜의 경계 사례까지 처리해 줍니다. 결국 저는 이 함수를 _Gleam_에 잘 맞도록 감쌌습니다:
% ewe v1에서 사용한 최종 버전
decode_packet(Type, Packet, Options) ->
case erlang:decode_packet(Type, Packet, Options) of
% HTTP 요청 라인
{ok, {http_request, Method, Uri, Version}, Rest} ->
{ok, {packet, {http_request, atom_to_binary(Method), Uri, Version}, Rest}};
% HTTP 헤더
{ok, {http_header, Idx, _, Field, Value}, Rest} ->
{ok, {packet, {http_header, Idx, Field, Value}, Rest}};
% 잠재적인 바디 데이터 또는 헤더 종료
{ok, Bin, Rest} ->
{ok, {packet, Bin, Rest}};
% 더 많은 데이터가 필요함
{more, undefined} ->
{ok, {more, none}};
{more, Length} ->
{ok, {more, {some, Length}}};
{error, Reason} ->
{error, Reason}
end.
또 하나의 큰 변화는 mist 웹 서버가 요청 바디를 처리하는 방식에서 영감을 받았습니다. 전체 바디를 메모리에 올리는 대신, 내부 Connection 타입을 사용자에게 노출했습니다:
pub type Connection {
Connection(
transport: Transport,
socket: Socket,
// ...
)
}
사용자는 ewe.read_body(req, 1024) 같은 함수를 호출해 1KB만 읽을 수도 있고, 애초에 바디가 필요 없는 요청이라면 무시할 수도 있습니다. 이는 예를 들어 WebSocket 같은 프로토콜 업그레이드 기회도 열어 줍니다.
이후 헬퍼 함수로 API를 확장하고 코드베이스를 다듬은 다음, 커뮤니티에 공유한 초기 버전인 0.3을 릴리스했습니다. Gleam 팀 멤버들과 _mist_의 제작자에게서 응원의 댓글을 받았습니다. 저는 관심을 정말 좋아하는 타입이라, 이게 제 패키지를 더더욱 잘 만들고 싶게 해 주는 큰 동기부여가 되었습니다. 어느 정도 시간이 흐른 뒤, keep-alive 동작을 구현했는데, 적절한 경우 클라이언트가 연결을 재사용할 수 있어 _HTTP/1.1_을 지원할 수 있게 됐습니다. HTTP/1.0 & _HTTP/1.1_만 지원하는 서버는 비교적 구현이 쉽다는 걸 알고 있었기 때문에, 다음 단계로 난도를 올리기로 했습니다. 내부적으로는 전혀 모르는 프로토콜, _WebSocket_을 향해요.
_WebSocket_은 단일 TCP 연결 위에서 양방향 통신 채널을 제공하는 프로토콜입니다. 클라이언트가 서버로 메시지를 보내고, 서버가 응답을 돌려줄 수 있으며, 응답을 받으려고 서버를 폴링할 필요가 없습니다. HTTP 위에 구축되어 있으며, 완전한 호환 구현을 만들려면 꽤 많은 단계를 거쳐야 합니다.
프로세스는 다음과 같은 HTTP 핸드셰이크 요청으로 시작합니다:
GET / HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
이는 연결이 WebSocket 프로토콜로 업그레이드될 것임을 나타냅니다. 또한 특별한 WebSocket 키가 포함되어 있으며, 서버는 이를 이용해 Sec-WebSocket-Accept 헤더가 있는 응답을 돌려줍니다:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
목표는 WebSocket 연결을 의도한 요청만 서버가 받아들이도록 하는 것입니다. Sec-WebSocket-Accept 값은 임의가 아닙니다. 클라이언트 키에 매직 문자열을 덧붙인 뒤 해시하고, 그 결과를 인코딩해 구합니다.
핸드셰이크 이후부터가 까다롭습니다. WebSocket 메시지는 프레임으로 도착합니다. 텍스트, 바이너리, ping/pong, close 같은 신호로 구성된 구조화된 패킷이죠. 한 메시지가 여러 프레임으로 분할되어 올 수도 있다는 점이 중요합니다. 그래서 소켓에서 지속적으로 읽고 이 프레임들을 처리할 방법이 필요했습니다.
첫 번째는 _TCP_에서 읽어오는 일입니다. 해법은 glisten 패키지 내부 파일을 보다가 배운 간단한 트릭을 쓰는 것이었습니다:
pub type ValidGlistenMessage {
// 새 데이터가 도착함
Packet(BitArray)
// 연결이 닫힘
Close
}
pub type GlistenMessage {
Valid(ValidGlistenMessage)
Invalid
}
// 간단히 말하자면, 이 함수는 TCP 이벤트를 "구독"합니다
fn select_valid_record(
selector: process.Selector(GlistenMessage),
binary_atom: String,
) -> process.Selector(GlistenMessage) {
process.select_record(selector, atom.create(binary_atom), 2, fn(record) {
decode.run(record, {
use data <- decode.field(2, decode.bit_array)
decode.success(Valid(Packet(data)))
})
|> result.unwrap(Invalid)
})
}
fn glisten_selector() {
process.new_selector()
// 일반 연결 포함
|> select_valid_record("tcp")
// 보안 연결 포함
|> select_valid_record("ssl")
// TCP 종료 이벤트 포함
|> process.select_record(atom.create("tcp_closed"), 1, fn(_) { Valid(Close) })
|> process.select_record(atom.create("ssl_closed"), 1, fn(_) { Valid(Close) })
}
_Erlang_에서는 TCP 소켓으로 데이터가 도착하면, 런타임이 {tcp, Socket, Data} 같은 형식의 메시지를 제어 프로세스에 보냅니다. process.select_record 함수는 이러한 메시지를 가로채는 데 도움을 줍니다.
셀렉터를 갖춘 뒤, 각 연결을 관리할 액터를 만들었습니다:
pub fn start(
transport: transport.Transport,
socket: socket.Socket,
handler: fn(ws.Frame) -> Next,
) {
actor.new_with_initialiser(1000, fn(subject) {
let conn = WebsocketConnection(transport, socket)
actor.initialised(State(conn, <<>>))
// TCP 이벤트를 청취합니다
|> actor.selecting(glisten_selector())
|> actor.returning(subject)
|> Ok
})
|> actor.on_message(fn(state, msg) {
case msg {
// 새 데이터 - 프레임 파싱 및 처리
Valid(Packet(data)) ->
handle_valid_packet(state, data, transport, socket, handler)
// 연결이 닫힘
Valid(Close) -> actor.stop()
// 데이터에 문제가 있음
Invalid -> actor.stop_abnormal(malformed_message_error)
}
})
|> actor.start()
|> result.map(after_start(_, transport, socket))
}
WebSocket 프레임은 복잡한 구조를 가집니다. opcode, 마스킹 정보 등등이 포함되죠. 다행히 gramps 패키지가 있어 비트 조작을 전부 직접 구현하지 않고, 파싱된 WebSocket 프레임을 다루는 데 집중할 수 있었습니다:
fn loop_by_frames(
frames: List(ws.Frame),
transport: transport.Transport,
socket: socket.Socket,
handler: fn(ws.Frame) -> Next,
next: Next,
) {
case frames, next {
// 조기 종료: 사용자가 중지를 요청함
_, Stop(Normal) -> Stop(Normal)
_, Stop(Abnormal(reason)) -> Stop(Abnormal(reason))
// 더 이상 프레임이 없으므로 여기서 종료
[], next -> next
// Ping에는 반드시 pong으로 응답해야 함
[ws.Control(ws.PingFrame(payload))], Continue -> {
let sent =
transport.send(transport, socket, ws.encode_pong_frame(payload, None))
case sent {
Ok(Nil) -> Continue
Error(_) -> Stop(Abnormal("Failed to send PONG frame"))
}
}
// 클라이언트가 종료를 원하므로 확인 응답 후 중지
[ws.Control(ws.CloseFrame(reason))], Continue -> {
let _ =
transport.send(transport, socket, ws.encode_close_frame(reason, None))
Stop(Normal)
}
// 데이터 프레임: 사용자 핸들러로 전달
[frame, ..rest], Continue -> {
case exception.rescue(fn() { handler(frame) }) {
Ok(Continue) ->
loop_by_frames(rest, transport, socket, handler, Continue)
Ok(stop) -> stop
Error(_) -> Stop(Abnormal("Crash in websocket handler"))
}
}
}
}
간단한 WebSocket 서버를 구현한 뒤, 사용자 정의 상태와 사용자 정의 메시지, 그리고 permessage-deflate(이것도 gramps 덕분)에 대한 지원까지 갖췄습니다. 클라이언트로 프레임을 다시 보내는 기능을 포함해 _WebSocket_의 공개 API도 더 명확해졌습니다. 그다음에는 전체를 다듬어 API가 과도하게 복잡하지 않도록 하고, 각 프로토콜이 요구 사항을 충실히 따르도록 했습니다.
프로토콜이 “돌아가게” 만드는 건 전투의 절반에 불과했습니다. 프로토콜을 명세대로 따르게 하고 API를 다듬는 일이 더 어려웠습니다. 예를 들어, 사용자 핸들러가 반환하는 각 응답은 반드시 ResponseBody를 포함해야 합니다. 처음에는 이게 opaque 타입이었고, ewe.text나 ewe.string_tree 같은 생성자를 케이스마다 제공했습니다. 서로 다른 Gleam 타입에서 응답 본문을 설정하고 필요한 헤더도 함께 지정하는 식이었죠. 사용자의 흐름은 다음과 같았습니다:
fn handle_request(req: Request(ewe.Connection)) -> Response(ewe.ResponseBody) {
case request.path_segments(req) {
["hello", name] ->
response.new(200)
|> ewe.text("Hello, " <> name <> "!")
_ ->
response.new(404)
|> ewe.empty()
}
}
아주 편리했지만, 이는 웹 서버 기능이라기보다 웹 프레임워크 기능에 가까웠습니다. 웹 서버의 역할은 최소한의 인터페이스를 제공하는 것입니다. 이를 고려해 ResponseBody 위에 있던 모든 추상화를 제거했고, 웹 서버 로직 바깥의 일은 사용자가 직접 처리하도록 했습니다:
fn handle_request(req: Request(ewe.Connection)) -> Response(ewe.ResponseBody) {
case request.path_segments(req) {
["hello", name] ->
response.new(200)
|> response.set_header("content-type", "text/plain")
|> response.set_body(ewe.TextData("Hello, " <> name <> "!"))
_ ->
response.new(404)
|> ewe.empty()
}
}
또 HTTP/1.1 RFC 명세를 준수하는지도 꼼꼼히 확인했습니다. 예컨대 HTTP 필드의 검증은 악의적인 클라이언트의 헤더 인젝션 공격을 막을 수 있습니다:
% HTTP 필드 값은 다음을 포함할 수 있습니다:
% - VCHAR: 0x21-0x7E (가시 ASCII 문자)
% - WSP: 0x20 (스페이스), 0x09 (탭)
% - obs-text: 0x80-0xFF (하위 호환)
% 유효하지 않음: 제어 문자 0x00-0x08, 0x0A-0x1F, 0x7F
do_validate_field_value(Value) ->
case Value of
<<>> ->
true;
<<C, Rest/bitstring>>
when C =:= 16#09
orelse C >= 16#20 andalso C =< 16#7E
orelse C >= 16#80 andalso C =< 16#FF ->
do_validate_field_value(Rest);
_ ->
false
end.
_WebSockets_에 대해서는 _Gleam_의 창시자인 Louis Pilfold가 WebSocket 구현에 Autobahn을 적용해 보라고 조언했습니다. 이 테스트 스위트는 가혹하지만 교육적이었습니다. 명세상 허용되지 않은 분할된 제어 프레임이나 125바이트 초과 ping 프레임 등 제 WebSocket 구현의 문제들을 드러내 주었거든요. 저는 내부의 중요한 문제를 고치기 위해 gramps 패키지에 PR도 올렸습니다. 모든 400+ 테스트가 초록색 “OK”로 뜨는 걸 봤을 때 정말 뿌듯했습니다. 개발자의 눈에는 축복 그 자체죠!
이 프로젝트를 진행하면서, 저는 졸업 논문 주제로 웹 서버를 삼고 싶다는 이야기를 교수님들과 나눴습니다. 전공이 컴퓨터 과학, 그중에서도 백엔드 개발이니 프로젝트 아이디어가 딱 맞아떨어졌습니다.
_WebSocket_을 끝낸 후에는 _ewe_를 다른 웹 서버들과 벤치마크해 보기로 했습니다. 높은 수치를 기대하진 않았고, 인기 서버들과 비교해 “충분히 괜찮다” 정도면 좋겠다고 생각했습니다. 그런데 제 벤치마크에 따르면 꽤 빠르더군요! (제 홈랩에 호스팅된 가상 머신 중 하나에서 돌린 것이므로, 결과는 다소 부정확할 수 있습니다.)
다만 중요한 디테일 하나를 완전히 잊고 있었습니다. 서버의 응답에는 서버에 시계가 있다면 Date 헤더가 포함되어야 합니다. 요즘엔 사실상 모든 장치에 시계가 있으니, Date 헤더는 구현되어야 하죠. _ewe_는 _mist_와 같은 방식으로 구현합니다. 시간을 계산하는 별도 애플리케이션을 두는 식이죠.
type Message {
Tick
}
// clock 모듈은 애플리케이션이므로 start와 stop 인터페이스가 있어야 합니다
pub fn start(_type, _args) -> Result(process.Pid, actor.StartError) {
actor.new_with_initialiser(1000, fn(subject) {
init_clock_storage()
set_http_date(calculate_http_date())
process.send_after(subject, 1000, Tick)
actor.initialised(subject)
|> actor.returning(subject)
|> Ok
})
|> actor.on_message(fn(subject, _msg) {
process.send_after(subject, 1000, Tick)
set_http_date(calculate_http_date())
actor.continue(subject)
})
|> actor.start()
|> result.map(fn(started) {
let assert Ok(pid) = process.subject_owner(started.data)
pid
})
}
pub fn stop(_state) {
atom.create("ok")
}
pub fn get_http_date() -> String {
case lookup_http_date() {
Ok(date) -> date
Error(Nil) -> {
logging.log(
logging.Warning,
"Failed to look up HTTP date, calculating a new one",
)
calculate_http_date()
}
}
}
시계 액터가 들어가면서 내부 단계가 더 필요해져 웹 서버가 조금 느려지긴 했지만, 여전히 자기 몫을 해냈고 _mist_와 비슷한 속도를 보여줬습니다! 무척 설렜습니다.
마침내 9월 중순, 저는 ewe 전용 Discord 채널에 첫 번째 릴리스 후보를 알렸습니다. 피드백과 내부 버그 리포트들을 받았고, 이를 빠르게 고쳤습니다. 그리고 공식 릴리스 전에 마지막 기능 하나를 더 구현하기로 했습니다. 바로 _Server-Sent Events_입니다.
_Server-Sent Events_는 서버가 HTTP 연결을 통해 클라이언트에 업데이트를 푸시할 수 있게 하는 기술입니다. _WebSocket_과 비슷하지만 단방향이며, 즉 클라이언트는 서버로 이벤트를 보낼 수 없습니다. 또 흥미로운 기능이 하나 있습니다. 연결이 끊기면 브라우저 같은 클라이언트가 자동으로 재연결을 시도합니다. 프로토콜 자체는 꽤 우아합니다. 흐름은 대략 이렇습니다:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
data: Wibble Wobble
event: user_joined
data: {"username": "admin"}
data: You can also split messages
data: across multiple data lines
_WebSocket_을 구현하고 나니, _Server-Sent Events_는 쉬운 작업이었습니다. 개발 과정에서 배운 거의 모든 것을 재사용했죠:
pub type SSEMessages(user_message) {
// 사용자 정의 메시지
User(user_message)
// TCP 연결 종료
Close
}
fn create_socket_selector(
user_subject: Subject(user_message),
) -> Selector(SSEMessages(user_message)) {
process.new_selector()
// 애플리케이션의 다른 부분에서 오는 메시지를 청취합니다
|> process.select_map(user_subject, fn(msg) { User(msg) })
// TCP 종료 이벤트를 청취합니다
|> process.select_record(atom.create("tcp_closed"), 1, fn(_) { Close })
|> process.select_record(atom.create("ssl_closed"), 1, fn(_) { Close })
}
셀렉터는 일반 tcp/ssl 이벤트를 청취하지 않습니다. 클라이언트가 서버로 메시지를 보낼 일이 없으니까요. 그런 다음, 이 메시지들을 처리하는 액터를 만들었습니다:
pub fn start(
transport: Transport,
socket: Socket,
on_init: fn(Subject(user_message)) -> user_state,
handler: fn(SSEConnection, user_state, user_message) -> SSENext(user_state),
on_close: fn(SSEConnection, user_state) -> Nil,
) -> Result(Selector(process.Down), actor.StartError) {
actor.new_with_initialiser(1000, fn(_subject) {
let subject = process.new_subject()
// 사용자가 자신의 상태를 초기화합니다
let state = on_init(subject)
let selector = create_socket_selector(subject)
actor.initialised(state)
|> actor.returning(subject)
|> actor.selecting(selector)
|> Ok
})
|> actor.on_message(fn(state, message) {
case message {
// 사용자 메시지를 핸들러에서 처리합니다
User(message) -> {
let conn = SSEConnection(transport, socket)
case handler(conn, state, message) {
Continue(new_state) -> actor.continue(new_state)
NormalStop -> {
on_close(conn, state)
actor.stop()
}
AbnormalStop(reason) -> {
on_close(conn, state)
actor.stop_abnormal(reason)
}
}
}
// 연결 종료
Close -> {
on_close(SSEConnection(transport, socket), state)
actor.stop()
}
}
})
|> actor.start()
|> result.map(fn(started) {
let assert Ok(pid) = process.subject_owner(started.data)
// 소켓 제어권을 액터로 이전합니다
let _ = transport.controlling_process(transport, socket, pid)
set_socket_active(transport, socket)
// 액터를 모니터링하는 셀렉터를 반환합니다
process.select_specific_monitor(
process.new_selector(),
process.monitor(pid),
function.identity,
)
})
}
이를 테스트하기 위해 간단한 실시간 채팅을 만들었습니다. 클라이언트는 SSE 엔드포인트에 연결하고, 누군가가 메시지를 POST로 보내면 연결된 모든 클라이언트에게 브로드캐스트합니다. 거의 한 번에 잘 동작했고, _WebSocket_에서 고생했던 걸 생각하면 정말 기분이 좋았습니다.
버전 1.0이 릴리스되면서, _ewe_는 HTTP/1.0, HTTP/1.1, WebSockets, _Server-Sent Events_를 지원합니다. 스트리밍 요청 바디, 청크드 응답, 파일 스트리밍도 다룹니다. v1로서는 훌륭한 시작입니다. 다음 목표는 _Gleam_에서 가장 인기 있는 웹 프레임워크인 Wisp에 _ewe_를 통합하는 것입니다. 지금은 _mist_에서만 작동하지만, 이미 _ewe_를 다른 웹 서버 프로바이더로 포함하기 위한 PR을 올려두었습니다.
그리고 HTTP/2가 있습니다. 솔직히 이건 완전히 다른 괴물이라, 명세를 이해하는 데만도 시간이 걸릴 겁니다. 제대로 구현하는 건 말할 것도 없고요. 하지만 불가능하진 않습니다. 천천히 현실로 만들 예정이니 걱정 마세요.
웹 서버를 개발하는 동안 Gleam 그 자체는 물론 네트워크와 프로토콜에 대해 많은 것을 배웠습니다. 개발 내내 Gleam 커뮤니티로부터 받은 모든 지원과 제안에 진심으로 감사드립니다. 제가 만나 본 공간 중 가장 따뜻하고 활발한 곳이고, 저는 앞으로도 _Gleam_을 제가 가장 사랑하는 기술로 계속 홍보하고 사용할 것입니다.