IT

[Erlang OTP]서버 만드는 법 (feat. gen_server)

BoBooBoo 2018. 6. 1. 09:58
반응형

?gen_server

gen_server(generic server, 제네릭 서버)는 서버 템플릿같은 것으로, 클라이언트-서버 모델에서 서버 측 기능을 지원하는 인터페이스 함수를 제공한다. gen_server에 미리 정의된 모든 인터페이스 함수는 각각 매칭되는 callback 함수를 가지고 있으며, 이 부분이 바로 사용자가 필요한대로 작성하게 되는 곳이다. 


?죽지 않는 서버

callback 함수가 실패하거나 올바른 값을 리턴하지 않으면 제네릭 서버 프로세스는 종료된다, 즉 서버가 죽는다. 그래서 제네릭 서버 프로세스가 죽으면 제네릭 서버의 모든 모듈을 사용할 수 없다. 서버를 안죽이려면 어떻게 해야 하는가? 물론 코드를 잘 짜서 콜백함수가 실패할 일이 없도록 예외처리를 꼼꼼히 하는 방법도 있지만, 예측하지 못한 상황에 대비하기 위해 supervisor라는 Erlang OTP와 함께 이용한다면 무중단 시스템을 운영할 수 있다. gen_server가 서버 템플릿이라면 supervisor는 감독관(?) 템플릿이라 할 수 있다. 프로세스들을 하위에 트리 형태로 구성해놓고 감시하면서 하위 프로세스들이 실패할 경우 재시작시키는데, 이는 다음 포스팅에서 정리해보겠다!


?효율적인 서버

callback 함수에 time-out 값 대신 erlang:hibernate/3 모듈을 사용하면 제네릭 서버를 잠자는 상태로 만들 수도 있다. 서버가 오랜 시간 놀 것 같은 경우에는 유용할 수도 있으나, 한 번 휴면상태에 들어가면 적어도 두 번 이상은 가비지 콜렉션을 수행하니 이 점을 주의해야 한다. 


* erlang:hibernate(모듈명, 함수명, 파라미터) -> no_return()


프로세스가 장시간 놀 것 같을 때 사용하는 것으로, 메모리 할당을 줄여 대기 상태로 만든다. 해당 프로세스의 메시지 큐에 새로운 메시지가 들어오면 휴면상태에서 깨어나 파라미터로 받은 함수를 수행하며 다시 활동을 재개하는 형태이다.


이 함수를 실행하면 해당 프로세스의 콜 스택을 비우고 가비지 컬렉션을 수행한다. 첫 번째 가비지 컬렉션을 거치고 나면 살아있는 모든 데이터들은 하나의 연속적인 힙에 위치하고 있는데, 이 힙 사이즈를 딱 데이터들이 가지고 있는 만큼으로 줄이는 것이다. (이 크기가 최소 힙 크기보다 작아도 무조건 데이터들이 차지하고 있는 사이즈로 줄어든다. 그리고 새로운 메시지가 들어와 프로세스가 깨어날 때, 다시 원래 설정한 최소 힙 크기로 돌아간다.)



?제네릭 서버 구성

우선 제네릭 서버를 생성하는 기본적인 모듈을 살펴보자. 아래 쓰여진 모듈만 작성하면 제네릭 서버가 구현된다. 사용자가 정의할 콜백 모듈을 제외하고 템플릿 코드가 약 20줄?도 안되는 것 같다. 처음 공부했을 때, 20줄 짜리 코드로 서버를 만들 수 있다니? 라는 놀라움도 있었지만, 무엇보다도 나는 콜백함수만 작성하면 된다는 점! 그게 가장 좋았던 것 같다.


 제네릭 서버 모듈

 콜백 모듈


gen_server:start

gen_server:start_link


Module:init


gen_server:stop


Module:terminate 


gen_server:call

gen_server:multi_call


Module:handle_call 


gen_server:cast

gen_server:abcast


Module:handle_cast 

 

 Module:handle_info

Module:terminate



?서버 구동

제네릭 서버는 start 혹은 start_link 함수를 호출하면 콜백 모듈인 init 함수를 호출하며 시작된다. start함수는 standalone 형태의 제네릭 서버 프로세스를 생성하고, start_link 함수는 supervision tree의 일부분으로 제네릭 서버 프로세스를 생성한다. 즉, supervisor(감독관)의 감시 하에 하위 프로세스로 제네릭 서버를 구동하는 형태이다. 


?서버 종료

또, stop함수도 start를 이용해 제네릭 서버를 시작한 경우, 제네릭 서버를 종료하는 역할을 하는 함수지만 start_link함수의 경우에는 supervisor가 알아서 종료시켜줄 것이므로 stop함수가 따로 필요하지는 않다. supervisor를 정리한 후, 이 두 부분을 좀 더 다뤄야 겠다. 


?클라이언트의 요청 처리

call 과 cast는 클라이언트의 요청을 처리하는 함수인데, 처리하는 방식이 동기냐 비동기냐로 구분되어 call은 동기식, cast는 비동기식이라고 하기도 한다. 클라이언트가 call함수를 이용해 서버에 무언가 요청한 경우, 콜백 모듈의 handle_call함수가 호출되어 해당 요청을 처리하고 요청에 대한 결과를 리턴한다. 하지만 클라이언트가 cast함수를 이용해 서버에 요청한 경우에는 handle_cast함수가 호출되는데, 클라이언트는 서버가 요청을 올바르게 처리했는지 알 수 없고 처리한 결과값도 받을 수 없다. handle_call, handle_cast외에도 handle_info라는 콜백함수가 존재하는데, 이 함수는 call도 cast도 아닌 클라이언트의 요청이 들어온 경우에 대한 콜백함수다. cast와 마찬가지로 비동기식이다. 예를 들면, 클라이언트의 요청이라기 보단 함께 구동중인 다른 프로세스에서 제네릭 서버의 종료가 필요하다고 할때, 종료 시그널 : {'EXIT', From, normal}을 보낸다던가 하는 경우가 있다. 


?소스코드


-module(genericServerTest).
-behaviour(gen_server).
-author("disneyra").

-export([start/0, stop/0, call/1, cast/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).

start() -> gen_server:start({local, ?MODULE}, ?MODULE, [], []).
init([]) -> {ok, running}.
stop() -> gen_server:stop(?MODULE).
terminate(Reason, State) -> ok.
call(CallReqArg) -> gen_server:call(?MODULE, CallReqArg).
handle_call(CallReqArg, _From, State) -> {reply, CallReqArg, State}.
cast(CastReqArg) -> gen_server:cast(self(), sys:get_state(?MODULE)).
handle_cast(CastReqArg, State) -> {noreply, State}.
handle_info(Info, State) -> {noreply, State}.


아래는 기본 템플릿 소스코드에 함수 호출 확인을 위한 출력 메시지들을 추가한 코드이다.

-module(genericServerTest).
-behaviour(gen_server).
-author("disneyra").
%% API
-export([start/0, stop/0, call/1, cast/1]).
-export([init/1,
        handle_call/3,
        handle_cast/2,
        handle_info/2,
        terminate/2]).


start() ->
  gen_server:start({local, ?MODULE}, ?MODULE, [], []).

init([]) ->
  {ok, running}.

stop() ->
  gen_server:stop(?MODULE).

terminate(Reason, State) ->
  io:format("[generic server is terminating]
              \t\t Reason : ~w
              \t\t State: ~w~n", [Reason, State]).

call(CallReqArg) ->
  io:format("[Recv call req] ~s\n", [CallReqArg]),
  gen_server:call(?MODULE, CallReqArg).

handle_call(CallReqArg, _From, State) ->
  io:format("[handle_call from ~w]
              \t\t MSG : ~s
              \t\t State : ~w~n", [_From, CallReqArg, State]),
  {reply, CallReqArg, State}.

cast(CastReqArg) ->
  io:format("[Recv cast req] ~s\n", [CastReqArg]),
  gen_server:cast(self(), sys:get_state(?MODULE)).

handle_cast(CastReqArg, State) ->
  io:format("[handle_cast]
              \t MSG : ~s
              \t State : ~w", [CastReqArg, State]).

handle_info(Info, State) -> io:format("[handle_info] \t Info : ~w \t State : ~w~n", [Info, State]), {noreply, State}.



우선 genericServerTest.erl 파일을 컴파일하고, 얼랭 쉘을 실행시킨다. 


컴파일 : erlc 모듈명.erl (ex. erlc genericServerTest.erl)

얼랭 쉘 실행 : erl

얼랭 쉘 내에서 컴파일 : c(모듈명). (ex. c(genericServerTest).



1. 서버 구동 : genericServerTest모듈의 start 함수를 호출한 모습이다. start 함수 내부에서 제네릭 서버의 start/4 함수를 호출하고, 해당 콜백모듈인 init/2 함수도 호출한다. 


gen_server:start(모듈명, 파라미터, 옵션) -> Result

gen_server:start(서버명, 모듈명, 파라미터, 옵션) -> Result (예제 사용 함수)

gen_server:start_link(모듈명, 파라미터, 옵션) -> Result

gen_server:start_link(서버명, 모듈명, 파라미터, 옵션) -> Result


Result = {ok, Pid} | ...


{ok, <0.62.0>}에서 <0.62.0>이 제네릭 서버 프로세스 ID에 해당함을 알 수 있다.



2. call 요청 : genericServerTest모듈의 call 함수를 호출한 모습이다. call 함수에서 [Recv call req] 메시지를 출력하고, 콜백함수인 제네릭 서버 handle_call 함수를 호출해서 [handle_call...~] 메시지가 출력되었음을 확인할 수 있다.


gen_server:handle_call(요청내용, From, State) -> {reply, 리턴할 값, NewState} | ...


여기서 주의할 점!!! 1번은 리턴값 형태 : {ok, Pid}가 그대로 출력되었으나, 2번은 {reply, call_test_message, running}이 아닌 call_test_messae가 출력되었다고 착각할 수 있다. 하지만 {ok, Pid}는 클라이언트(요청한 프로세스)에게 전달하는 리턴값이 아니다. 지금은 서버와 클라이언트가 한 쉘에 있어서 서버 측 출력내용 + 클라이언트가 받는 내용이 모두 출력된다. {ok, Pid}는 그저 gen_server:start 함수의 결과값일 뿐이다. 서버와 클라이언트가 각각 다른 곳에 있다면 클라이언트에게는 {ok, Pid}가 출력되지 않는다. 이건 서버 측 함수 수행의 결과값이니까 gen_server:start 함수를 호출한 프로세스에게 전달되는 것이다. 


그렇다면, reply와 NewState는 어디로 갔는가? reply는 제네릭 서버-클라이언트 간 메세지를 주고 받을 때, 메세지를 구분하는 태그로 사용되고 NewState는 제네릭 서버의 새로운 상태값으로 사용된다. 그러니까 gen_server:handle_call 함수의 리턴값은 사실 {클라이언트의 call요청에 대한 응답임을 의미하는 제네릭 서버 내 메세지 태그, 실제 클라이언트에게 리턴되는 값, 제네릭 서버의 새로운 상태값} 이라고 보면 된다. 서로 다른 얼랭 프로세스들끼리 메세지를 주고 받을 때 이 방법을 유용하게 써먹었다. 튜플: { } 의 제일 앞에는 메시지를 구분할 수 있는 태그, 프로토콜이 들어가는 자리로 사용하는 것이다. 



3. cast 요청 : genericServerTest모듈의 cast 함수를 호출한 모습이다. cast 함수에서 [Recv cast req] 메시지를 출력하고, 콜백함수인 제네릭 서버 handle_cast 함수를 호출해서 ok를 리턴하였다.


gen_server:handle_cast(요청내용, State) -> {noreply, NewState} | ...



4. 서버 종료 : genericServerTest모듈의 stop 함수를 호출한 모습이다. 콜백함수인 제네릭 서버 terminate 함수를 호출해 [generic server is terminating]을 출력했다. (stop 함수에 로그 찍는걸 깜박했나보다..)


gen_server:terminate(Reason, State) 


Reason = normal | ...



5. handle_info가 호출되는 경우 : call이나 cast가 아닌 요청을 하면 handle_info 함수가 처리한다고 하니, 제네릭 서버에 일반 메시지를 보내보자. 메시지를 보내려면 먼저 제네릭 서버의 프로세스 ID를 알아야 한다. 제네릭 서버를 실행시키면 {ok, PID}를 리턴받으므로 이 값을 GenServerPID라는 변수에 저장해서 사용할 것이다. 


변수에 값이 잘 들어갔는지 확인하고


테스트 메세지를 보내보니, 제네릭 서버의 handle_info 함수가 호출되어 [handle info] 가 프린트 된 것을 알 수 있다. 


이번에는 종료 시그널을 보내보자. {'EXIT', GenServerPID, normal} 은 다른 프로세스로부터 종료 요청을 받을 경우 제네릭 서버가 받는 시그널인데 메시지를 통해 바로 보내보았다.


stop 함수를 호출하지 않았는데 제네릭 서버의 terminate 함수가 호출된 것을 볼 수있다. 올바르게 종료 시그널을 받은 것이다. 진짜 종료된 것일까?? 궁금하면 is_process_alive 함수를 통해 확인할 수 있다. 파라미터로 들어가는 프로세스가 살아있으면 true, 죽었으면 false를 반환한다. 또, 제네릭 서버를 구동해보면 이전 서버가 확실히 죽었는지 알 수 있다.


동일한 이름의 제네릭 서버가 이미 켜진 상태에서 또 켜려고 시도하면 already started라는 아래와 같은 메세지가 뜨기 때문이다. 


gen_server:handle_info(Info, State) -> {noreply, NewState} | ...




끝!!! 쉽고 빠르게 서버를 만들어 보았다.

입맛에 맞게 handle_call/cast/info부분을 수정하면 된다.

좋은 기능!!! 





반응형