일단 양해를 구할 것이 있다. 나는 Erlang을 위한 적절한 코드 하이라이터를 아는게 없어서 아래 코드부분에서 검은건 화면이요 하얀건 글씨다. 코드와 주석은 우리 마음속에서 잘 구분해 보도록 하자.
일반적으로 인터넷 상의 함수형 언어에 대한 설명들에서 내가 마음에 들지 않는 부분은 소개되는 코드들 중 실 생활에서 사용 할만한 코드가 거의 없다는 점이다. 아마 함수형 언어들이 현장에서 쓰이는 빈도가 적다보니 대부분 공부용 코드 이외의 것을 보기 어려운 것이 아닌가하는 생각이 든다. 그래서 나는 함수형 언어도 현장에서 써먹을 수 있다는 증거(?)를 블로그에 남기고자 잘 써먹고 있는 TCP 서버 코드를 잘라다가 소개하기로 했다. 이 서버의 디자인은 기본적으로 http://20bits.com/article/erlang-a-generalized-tcp-server 글에 소개된 간단한 OTP 기반 TCP서버 디자인에 기초하고 있다.
이 코드는 OTP gen_server에 기초를 두고 있고, 소켓 단위로 프로세스가 분리되어 비동기적인 작동을 한다. 아래 코드는 작동하는 서버에서 일부분만 잘라낸 것이어서 그 자체로는 아무 작동도 하지 못한다. 무언가 구상한 것이 있다면 이 서버 코드의 파편에 살을 붙여 멋진 애플리케이션으로 살려보기 바란다 :) 참고로 이 코드는 내가 짠 캐시 서버의 일부다. 그 서버에는 지금의 코드보다 더 철저한 애러 핸들러와 잘 작동하는 데이터 파서, gen_server를 통한 ETS 및 데이터 조회, 캐시 삭제를 하는 코드가 포함되어 있다.
-module(tcp_network).
-behaviour(gen_server).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(TCP_OPTIONS, [binary, {active, once}, {recbuf, 4096}, {reuseaddr, true}]).
%% ====================================================================
%% API functions
%% ====================================================================
-export([open/1]).
open(Port) ->
gen_server:start_link({local,?MODULE}, ?MODULE, Port, []).
%% ====================================================================
%% Behavioural functions
%% ====================================================================
%% init/1
init(Port) ->
case gen_tcp:listen(Port, ?TCP_OPTIONS) of
{ok, Listen} ->
%% server state가 만들어진다
%% {tcp listen pid, tcp listen}의 튜플이다
{ok, accept({self(), Listen})};
{error, Reason} ->
{stop, Reason}
end.
%% async cast 다음 소켓에 대한 accept_loop를 준비시킨다
%% handle_cast, accept, accept_loop의 3개 함수는 서로재귀 상태에 있다
%% 서로재귀 함수임에도 accept_loop는 나머지 함수와는 다른 프로세스에 있다
handle_cast({accepted}, State) -> {noreply, accept(State)}.
handle_call(_Request, _From, State) -> {noreply, State}.
handle_info(_Info, State) -> {noreply, State}.
terminate(_Reason, _State) -> ok.
code_change(_OldVsn, State, _Extra) -> {ok, State}.
%% ====================================================================
%% Internal functions
%% ====================================================================
%% 다음 소켓 연결 요청을 처리할 프로세스를 만들어 대기시킨다
accept({Server, Listen}) ->
proc_lib:spawn_link(fun() -> accept_loop(Server, Listen) end),
{Server, Listen}.
accept_loop(Server, Listen) ->
case gen_tcp:accept(Listen) of
{ok, Socket} ->
%% 소켓 요청이 받아진 경우
%% cast에 다음 소켓에 대해 준비할 것을 요청한다
gen_server:cast(Server, {accepted}),
%% 이 프로세스는 소켓 요청 대기를 끝냈다
%% 소켓 데이터 처리를 위한 재귀 루프로 들어간다
receiver(Socket);
{error, _Reason} ->
%% 소켓 요청에 실패한 경우
%% 이 예에서는 일단 다음 소켓에 대한 준비를 요청한다
%% 상황에 따라 이 오류를 처리할 cast를 따로 준비해야 할 수 있다
gen_server:cast(Server, {accepted}),
%% ......
%% 그 밖에 필요한 오류 처리절차
%% ......
%% 처리가 끝났다면 프로세스를 종료
exit(normal)
end.
%% receiver는 한 소켓을 처리한다
%% receiver와 이를 호출한 accept_loop는 같은 프로세스다
receiver(Socket) ->
receive
{tcp, Socket, Bin} ->
%% Bin은 TCP로 들어온 바이너리 데이터
%% ......
%% 데이터 처리에 필요한 작업들
%% ......
%% 서버는 {active, once} 옵션으로 throttle이 조정되고 있다
%% 작업이 끝났다면 다음번 데이터를 받도록 {active, once} 상태로 돌아간다
inet:setopts(Socket, [{active, once}]),
%% 다음 데이터를 받는 재귀
receiver(Socket);
{tcp_closed, Socket} ->
%% 소켓이 닫힌다면 프로세스 종료
exit(normal)
end.
한 소켓으로 들어온 각각의 요청을 비동기적으로 처리하고자 한다면 receiver에서 받은 데이터를 처리하는 프로세스들을 추가로 생성하는 것을 고려해볼 수 있을 것이다. 특히 각 요청들이 서로간에 처리시간이 상이하여 동기적인 처리가 비효율적이고 client가 비동기적인 response를 수용할 수 있는 경우라면 receiver에서 한번 더 worker를 spawn해서 처리를 나누는 것은 필수적이라 할 수 있다. 또한 작은 작업 단위로 프로세스를 나누는 것은 멀티코어 CPU의 자원을 활용하도록 하는 Erlang의 최적화 전략이라는 것도 명심하자. 일반적으로 요청의 처리는 적절한 수준으로 쪼개는 것이 좋다.
이 코드는 TPKT 같은 헤더가 없으며, 데이터는 recbuf에서 잘린다. TPKT로 길이를 알려주면서 통신하고 싶다면 TCP_OPTIONS에 {packet, tpkt} 튜플을 옵션으로 넣으면 된다.