규도자 개발 블로그
엘릭서(Elixir)라는 약을 팔아보자 본문
엘릭서(Elixir)라는 약을 팔아보자
엘릭서라는 이름 자체는 많은 사람들이 익숙할 거라 생각한다. 어쩌면 이름 자체는 파이썬이라는 이름보다 더 인지도가 있지 않을까 할 정도로 꽤나 자주 마주치게 되는 단어이다.
특히나 엘릭서라는 이름을 많은 사람들에게 알린 계기는 단연코 메이플이 아닐까 싶다. 이밖에도 클래시 로얄이나 리니지 등 수많은 게임 및 매체에서 사용되는 이름이다.
요 근래 엘릭서 프로그래밍 언어를 공부하면서 정말 좋은 언어라는 걸 많이 느낀다. 하지만 이직을 하게 되면서 현업에 적용해볼 기회가 없을 것이기 때문에 그간 배우면서 느꼈던 엘릭서의 장점들을 한번 나열해보려 한다. 말 그대로
엘릭서(Elixir)라는 약을 팔아보려 한다.
엘릭서의 유일한 약점은 "생태계"인 것 같다. Functional Language를 쓰는 사람도 적은데 Elixir는 그러한 언어들 중 하나니까 말이다. 그럼에도 불구하고 Functional Language중에선 가장 많은 비율을 차지하고 있긴 하다. (참고: 인디드(indeed)로 살펴본 프로그래밍 언어의 실력대비 연봉 테이블)
외국 포럼에서 검색해봐도 엘릭서의 단점으로 꼽는 것은 "사용자가 적다. 그래서 개발자 구하기가 힘들다"가 대부분이다. 그만큼 다른 것들은 단점으로 꼽을 수 없을 정도로 언어나 프레임워크 그 자체는 굉장히 완성돼있다는 것의 반증이라고도 할 수 있겠다. 아무튼 이제부터 엘릭서의 장점들을 소개해보겠다.
0. 처우(연봉)
원래는 없는 파트였는데 이 포스트를 작성하다가 추가하게 됐다. 그리고 심지어는 가장 중요한 부분이기도 하고 가장 매력적인 부분이기도 하기 때문에 기꺼이 0번을 차지할 이유가 된다. 뒤늦게 추가된 이유는 바로 어제 발표된 따끈따끈한 stackoverflow 2022년 전반기 설문 때문이다. (https://survey.stackoverflow.co/2022/)
러스트에 이어 두번째로 사랑받는 언어이면서도 (링크)
가장 사랑받는 웹프레임워크가 elixir의 웹프레임워크인 phoenix이고 (링크)
연차대비 최상위권의 연봉을 받고 있다. (링크)
일단 이것만으로도 엄청난 매력이다.
1. 파이프 연산자 ( |> )
개인적으로 내가 엘릭서에서 가장 좋아하는 부분이다. 엘릭서로 하는 코딩을 재미있게 만들어주는 부분이기도 한데 기능을 간단하게 설명하자면 파이프 연산자는 전에 실행된 함수의 결과값을 다음 함수의 첫번째 인자로 던진다. 백문이 불여일견, 한번 보면 이해가 될 것이다.
1..45
|> Enum.to_list
|> Enum.shuffle
|> Enum.take(6)
|> IO.inspect
로또번호 생성기다. 이게 끝이다.
만약에 파이프라인연산자가 없다면 위 코드는 아래 형태가 된다. 그리고 이것은 우리가 전통적인 프로그래밍 언어에서 많이 볼 수 있는 형태이다.
number_list = Enum.to_list(1..45)
shuffled = Enum.shuffle(number_list)
IO.inspect(Enum.take(shuffled, 6))
둘 중 어떤 코드가 더 읽기 쉬울까. 고민의 여지가 없다. 우리가 글을 읽는 방향과 동일한 순서로 진행되는 위 형태의 코드가 훨씬 가독성이 좋다. 그리고 이러한 파이프연산자는 선언형 프로그래밍에 보다 적합한 형태의 코드를 짜는데 도움이 된다. 그리고 위 코드도 elixir built-in 함수들을 활용해서 만들었기 때문에 그나마 3줄로 끝났지만 다른 언어라면 보다 장황하게 작성될 가능성이 있다.
2. 정말 쉬운 병렬화와 동시성 문제 해결
엘릭서(erlang)는 java나 C#처럼 VM위에서 실행된다. java가 jvm에서 실행되고 C#이 닷넷 머신에서 실행되듯이 엘릭서는 BEAM이라고 하는 머신에서 실행된다. 그래서 엘릭서에서 의미하는 프로세스는 OS레벨에서 실행되는 프로세스가 아니고 BEAM에서 자원을 할당받아 실행되는 초경량 프로세스이다. 어느정도로 초경량이냐면
defmodule Chain do
def counter(next_pid) do
receive do
n ->
send next_pid, n + 1
end
end
def create_processes(n) do
code_to_run = fn (_, send_to) ->
spawn(Chain, :counter, [send_to])
end
last = Enum.reduce(1..n, self(), code_to_run)
send(last, 0)
receive do
final_answer when is_integer(final_answer) ->
"Result is #{inspect(final_answer)}"
end
end
def run(n) do
:timer.tc(Chain, :create_processes, [n])
|> IO.inspect
end
end
위 코드는 한빛미디어에서 발매한 "처음 배우는 엘릭서 프로그래밍"에 나온 chain spawn예제이다. 책 진짜 좋으니 강추한다. (https://m.hanbit.co.kr/store/books/book_view.html?p_code=B5732906061)
코드를 설명하자면 def run(n)
이라는 함수의 매개변수에 들어가는 숫자만큼 Chain이라는 module을 spawn해서 counter를 실행시키는 것인데 그냥 직관적으로 이해하자면 n에 들어가는 만큼의 프로세서가 생성됐다가 죽는다고 보면 된다. 이 프로그램을 간단히 돌려보면
% elixir -r chain.exs -e "Chain.run(100000)"
{365545, "Result is 100000"}
# 0.36초
% elixir --erl "+P 1000000" -r chain.exs -e "Chain.run(1_000_000)"
{3780307, "Result is 1000000"}
# 3.78초
이만큼의 시간이 걸린다. 내 맥북의 스펙은
이렇다. 스펙을 감안하더라도 100만개의 프로세스를 생성했다 지우는데 약 3.8초라는 시간은 정말 매력적이지 않을 수가 없다. 그리고 위 명령어를 time이라는 명령어와 함께 실행해보면
% time elixir -r chain.exs -e "Chain.run(100000)"
{395579, "Result is 100000"}
elixir -r chain.exs -e "Chain.run(100000)" 1.58s user 0.45s system 165% cpu 1.230 total
% time elixir --erl "+P 1000000" -r chain.exs -e "Chain.run(1_000_000)"
{3866073, "Result is 1000000"}
elixir --erl "+P 1000000" -r chain.exs -e "Chain.run(1_000_000)" 9.88s user 3.22s system 259% cpu 5.041 total
이렇게 멀티 CPU를 넉넉하게 잘 잡아서 쓰는 걸 확인할 수 있다.
3. TCO
TCO. Tail Call Optimization(꼬리 재귀 최적화)의 약자이다. 보통 재귀함수는 함수 오버헤드때문에 자주 사용되지 않는다. 코딩인터뷰를 볼때도 항상 재귀의 단점을 함수 오버헤드와 스택오버플로우의 위험성이라고 뭔가 교과서처럼 답변하는 게 당연하듯이 말이다.
재귀함수는 결국 마지막에 함수가 종료되기 전까진 함수를 메모리에 올려놓는다. 피보나치 수열의 n번째 숫자를 구하는 함수를 python으로 구현해보면
def fib(n):
if n == 0:
return 0
elif n == 1 or n == 2:
return 1
else:
return fib(n - 1) + fib(n - 2)
이런 모습이 되는데 10번째 위치의 숫자를 구하는 함수를 실행해봐도 (fib(10)
)
순식간에 엄청난 함수 스택이 쌓이는 걸 확인해볼 수 있다.
근데 하다보니까 factorial이 훨씬 더 직관적으로 함수 스택이 쌓이는 걸 확인해볼 수 있을 것 같다. factorial을 recursive로 구하는 함수는
def factorial(n):
if n == 1:
return n
else:
return n * factorial(n-1)
이것인데 이게 좋은 이유가 함수 스택이 늘었다 줄었다 하는 피보나치와는 달리 n이 1에 도달하기 전까진 스택이 계속해서 쌓이다가 한번에 계산이 되므로 절차를 파악하기 훨씬 더 용이하다. factorial(10)
을 실행해보면
이렇게 n이 2일때까지는 꾸준히 함수스택이 쌓이다가 1이 되면 한번에 계산된다.
그래서 우리는 이러한 현상을 방지하기 위해 메모이제이션같은 디자인 패턴을 사용하지만 보통 프로그래밍 언어가 순회문을 돌리기 위해 사용하는 변수 재할당(for(int i=0;i<10;i++)
)이라는 것이 함수형 프로그래밍 언어에는 없으므로 보통 패턴매칭과 재귀를 이용해 순회나 반복 기능을 구현한다. 하지만 재귀함수에는 위와 같이 함수 오버헤드라는 치명적인 단점이 존재한다. 그것을 해결한 게 바로 꼬리재귀최적화(TCO)이다.
엘릭서는, 함수에서 마지막으로 수행하는 연산이 자기 자신으로의 호출일 때는 함수를 추가로 호출하지 않고(stack에 쌓지 않고) 그냥 런타임이 함수의 시작 부분으로 돌아간다. 재귀 호출에 인자가 있다면 기존 파라미터의 값이 새로운 인자로 대체된다. 함수에 TCO를 적용하기 위해선 단순히재귀호출이 함수의 마지막에 실행되면 된다.
defmodule Recursive do
def factorial(0), do: 1
def factorial(n), do: n * factorial(n-1)
end
코드상으로는 재귀 호출이 마지막에 있지만 마지막에 실행되는 연산은 아니다. 재귀 호출이 값을 반환한 뒤에도 곱셈이 남는다. 꼬리 재귀로 바꾸려면 곱셈을 재귀 호출 안으로 옮겨야 한다. 즉, 누적된 값을 저장하는 추가 패러미터가 필요한 것이다. 위 함수에 꼬리재귀 최적화를 적용하면 이러한 형태가 된다.
defmodule TCORecursive do
def factorial(n), do: _fact(n, 1)
defp _fact(0, acc), do: acc
defp _fact(n, acc), do: _fact(n-1, acc*n)
end
꼬리재귀최적화고 나발이고 이걸 어따가 쓰냐고 하겠지만 이건 엘릭서로 만드는 서버에 엄청난 강점이 된다. 엘릭서는 서로의 프로세스에 접속해서 다른 프로세스가 갖고 있는 함수를 실행할 수 있는데 클라이언트가 서버에 있는 함수를 실행하고 나면 서버 프로세스의 런타임이 함수 종료시점으로 옮겨짐으로 인해서 그 함수를 또 실행해보려 해도 실행이 되지 않는다. 하지만 프로세스가 call 당했을 때 절차를 모두 종료하고 단순히 자기자신을 한번 더 불러오는 방법으로 아주 쉽고 간단하고 세련되게 함수 스택을 더 늘리지 않고 계속 같은 동작을 반복하게 할 수 있다.
defmodule Spawn do
def greet do
receive do
{sender, msg} ->
send sender, { :ok, "Hello, #{msg}" }
greet() # 바로 이부분
end
end
end
pid = spawn(Spawn, :greet, [])
send pid, {self(), "World!"}
receive do
{:ok, msg} ->
IO.puts msg
end
send pid, {self(), "Kermit!"}
receive do
{:ok, msg} ->
IO.puts msg
after 500 ->
IO.puts "The greeter has gone away"
end
위 스크립트를 실행하면
Hello, World!
Hello, Kermit!
이런 결과가 나온다. 만약에 위에 표시한, greet()
을 통해 자기 자신을 한번 더 호출하지 않으면
Hello, World!
The greeter has gone away
이렇게 TIMEOUT 문구가 출력된다.
다시한번 강조하고 싶다. 엘릭서는 프로세스가 call 당했을 때 절차를 모두 종료하고 단순히 자기자신을 한번 더 불러오는 방법으로 아주 쉽고 간단하고 세련되게 함수 스택을 더 늘리지 않고 계속 같은 동작을 반복하게 할 수 있다. 정말 대단하다. 그리고 깨알같이 after 500 ->
같은 가독성 좋은 코드도 너무 좋다.
+ 추가로 알게 된 사실인데 TCO는 자기 자신으로의 호출에 한정되는 것이 아니라 그냥 "함수 호출이 마지막 표현식일 때" 적용된다.
defmodule A do
def a() do
B.b()
end
end
defmodule B do
def b() do
A.a()
end
end
A.a()
예를 들어 이렇게 서로 다른 곳에 있는 함수를 호출해도 stackoverflow 없이 영원히 작동한다. 궁금하면 실제로 exs파일을 만들어 실행해보자.
하지만 비슷한 흐름의 코드를 python으로 짜서 실행해보면
def testA():
testB()
def testB():
testA()
testA()
바로 이렇게 터져버린다. 오류 내용이 직관적이다. RecursionError: maximum recursion depth exceeded. 함수 call을 stack에 계속 쌓아놓는 형태로 작동하기 때문에 이러한 에러가 발생하는 것이다.
4. 무중단배포 (업그레이드 릴리즈, 혹은 핫 코드 업그레이드)
위의 영상을 erlang을 만든 사람들이 릴리즈 기념 & 안내차 만든 영상이다. 제목은 erlang: The movie 이지만 이 영상에 나온 기능들은 erlang기반으로 만들어진 Elixir에서도 전부 지원한다. 내가 영상 재생 지정 시간부터가 핫 코드 업그레이드에 대한 내용이다. 영상을 보기 귀찮은 사람도 있을 수 있으니 대충 설명을 하자면 이름에서 유추할 수 있다시피 이미 메모리에서 작동되고 있는 서비스의 코드를 실시간으로 업데이트할 수 있다.
영상의 내용을 말로 간단하게 설명하자면 등장인물들(erlang의 제작자들)이 3인 통화(컨퍼런스 콜)를 시도하는데 실제 돌아가고 있는 서비스에 undefined 에러가 발생하게 되고 확인해보니 함수이름이 잘못돼있어서 해당 부분을 고쳐서 핫 코드 업데이트 시킨다. 현대로 비유하자면 트위치로 실시간 방송을 보고 있는데 채팅기능에 버그가 발견되어서 실시간 방송이나 웹서비스의 중단 없이 해당 기능을 픽스시킨 것이다.
엘릭서는 웹소켓이나 채널의 끊김 없이도 진정한 의미의 무중단배포와 fix가 가능하다. 내가 아는 한 이런 기능을 제공하는 언어나 프레임워크는 없다. 전화국 프로그램을 제작한다는 프로그래밍 언어의 탄생 목적에 적합한 기능이라고 할 수 있다. 소프트웨어를 업데이트한다고 전화가 끊기면 안되니 말이다.
5. Phoenix Framework
또한 엘릭서로 만든 웹프레임워크가 존재한다. 깃허브 레포지토리는 https://github.com/phoenixframework/phoenix 이곳이다. 이것을 이용하면 위에서 말한 모든 장점들을 활용할 수 있는 웹 어플리케이션을 제작할 수 있다. 우리가 흔히 스케일업할 때 고민하는 것들을 단순히 머신을 추가하고 프로세스를 추가하는 것으로 해결할 수 있다.
그리고 위에서 계속 말했던 경량프로세스를 이용해 피닉스는 사용자 개개인과의 채널을 유지할 수 있다. 브라우저가 아닌 서버단에서 사용자의 상태를 관리할 수 있다는 말이다.
이 그림을 보면 보다 이해가 쉽다. 이것은 Phoenix Liveview라는 라이브러리를 설명하는 글에 있는 그림이지만 애초에 Phoenix의 채널 기능을 이용하여 Liveview가 구현된 것이므로 Phoenix의 기능이라고 봐도 무방하다. Phoenix Liveview의 github repo는 https://github.com/phoenixframework/phoenix_live_view 이곳인데 이곳에서 데모영상을 보면 서버단에서 state를 관리하는 것이 얼마나 강력한 힘인지 체험할 수 있다.
사실 이밖에도 수많은 장점들이 있지만 너무 장황해질까봐 장점에 대한 나열은 여기까지만 하려고 한다. 실제로도 구글에 elixir pros and cons라고 쳐봐도 pros는 정말 많은 반면에 cons는 "reference가 적다. 사용인구가 적다. 생태계가 적다" 이정도이다. 그정도로 elixir는 어플레케이션을 만드는 다른 전통적 프로그래밍 언어들의 고질적인 문제를 예전에 해결해놓은 상태이다. 하지만 유명하지 않다는 이유로 선택을 받고 있지 못하다. 근데 그런 현실적인 문제도 어쩔 수 없긴 하다. 나 같은 입장의 프로그래머는 기술스택을 선택하는 데에 있어서 굉장히 제한적이니까 말이다.
세계 최대 VC중 하나라고 할 수 있는 Y-Combinator의 창립자 폴 그레이엄이 쓴 해커와 화가라는 책이 있다. 그중에서도 후반부 파트가
이런식으로 구성이 돼있는데 10번, 11번, 그리고 14번 챕터가 언어에 대한 부분이다. 그리고 만약 엘릭서를 알고 있는 사람이 저 파트를 읽어본다면 폴 그레이엄이 생각하는 궁극의 언어가 엘릭서와 많이 닮아있다는 점을 느낄 것이다. 그 분량이 또 거의 이 포스팅 전체와 맞먹으니 하나하나 설명하긴 그렇고, 폴 그레이엄이 말하는 궁극의 언어란 무엇인가에 대해서 요지를 설명해보자면
컴퓨터의 시간을 낭비해서 프로그래머의 시간을 확보해줄 수 있는 언어
라고 할 수 있다. 그런 면에 있어서 개발자들의 많은 리소스를 낭비하는 스케일링에 대한 문제나 동시성에 대한 문제를 일찌감치 해결해버린 엘릭서는 훌륭한 대안이 될 것이다. 폴 그레이엄이 직접적으로 엘릭서를 언급하진 않는다. 왜냐. 엘릭서는 2011년에 나왔고 저 책은 2004년에 나왔으니 말이다.
1990년대부터 함수형 언어인 LISP으로 개발을 했고, 굴지의 VC를 일궈낸 폴 그레이엄의 안목이라면 꽤나 믿을만하지 않을까? 권위는 실력과 시간, 운과 환경 등 많은 요소들이 상호작용해야만이 생기는, 아주 얻기 어려운 것인데 폴 그레이엄은 권위있는 전문가이다. 프로그래밍에서도, 벤처투자에서도.
차세대 언어에 관심이 있다면 엘릭서를 자신있게 추천한다. 결정이 어렵다면 폴 그레이엄이라는 권위에 기대자. 이 포스팅의 제목도 그러한 연유로 "약을 판다"고 지은 것이다. 이 엘릭서라는 약이 정말 좋은데 말로 표현을 다 못하겠네(여태 말로했다). 백문이 불여일견 엘릭서 책을 한번 떼면 누구나 엘릭서의 팬이 돼있을 거라 자부한다.
'FP > Elixir' 카테고리의 다른 글
2022 LiftIO 후기 (부제: 개발자 부족의 시대, Elixir가 특효약이 될수 있는 이유) (0) | 2022.12.27 |
---|---|
세 줄로 엘릭서(elixir) 시작하기 (0) | 2022.05.27 |