[ElixirConfEU] Clarity session 리뷰
영상 소개
가장 최근에 깨달음을 준 컨퍼런스 세션 영상을 하나 소개하고자 한다. 원래도 컨퍼런스 세션같은건 많이 찾아봤었는데, 이제 그냥 흘려보내지 않고 블로그에도 한번씩 기록해두려고 한다.
주제 : 명료함
영상의 주제는 간단하다. 명료함(Clarity)이다. 구체적으로는 사람과 사람, 사람과 기계 사이의 소통에 중요한 단 한 가지 속성으로써 Clarity를 제시한다.
가장 크게 와닿은 점
다음 2가지가 가장 와닿았다.
- Phoenix의 책에 소개되었고 generator도 기본적으로 생성해주는 구조는 온보딩 측면에서는 좋지만 실제로 프로젝트가 커질 때 명료성을 나타내기 좋은 구조가 아니다.
- 레이어를 Interface + Core 로 구성하는 Agency Style의 제안. 양파 아키텍처, 육각형 아키텍처, 클린 아키텍처 등 그런 아키텍처를 처음부터 설계하는게 더 프로젝트 복잡성을 늘릴 수 있다. Infra와 Business Logic은 차라리 묶어놓고 생각하는 것이 좋다.
그동안 아키텍처 공부하면서 “프레임워크는 라이브러리일 뿐이다. 프레임워크에 의존한 아키텍처를 설계하면 안된다.” 라고 누누히 되새겨왔으면서도, 정작 Elixir 프로젝트를 놓고 보면서 피닉스의 기본 구조를 벗어난 구조를 떠올리기가 쉽지 않았다. 뭔가 안전한 둥지를 벗어나는 느낌? 조금 더 적극적으로 확장 가능한 나만의 프로젝트 구조를 설계해봐야겠다.
육각형(양파, 클린, 함수형) 아키텍처에도 그동안 약간 과몰입하면서, 예전에 디자인패턴을 공부하면서 느꼈던 멘탈모델 과정을 다시 되풀이하는 느낌도 들었다. 결국 내가 추구하고싶었던건 아키텍처의 단순함이었다.
육각형 아키텍처는 2가지 레이어만 생각하면 되고, 기존의 객체지향에서의 SOLID원칙 처럼 5가지나 생각하면서 디자인을 하지 않아도 되니까 분명 심플한 아키텍처인데, 왜 정작 설계를 막상 하려면 어려운걸까? 에 대한 의문이 계속 들었는데, 아무래도 Haskell과 함수형 디자인을 공부하면서 함정에 빠진것 같다.
외부와 상호작용하는 Infra 부분과 Business Core 모듈은 사실 나누기가 까다롭다.
특히 처음 추가하려는 기능이 그냥 CRUD적인 기능 일때, 그런데 그 부분이 언제 어떻게 복잡한 로직으로 변할지는 모른다. 다만 지금 당장은 Domain Model이라고 할만한 것이 그냥 디비의 스키마 모델인데, 디비 테이블에 의존하는 스키마 선언과 도메인 모델을 분리하기 위해 똑같은 필드를 가진 struct를 Domain Model로써 따로 선언하는것이 맞는가? 이러한 부분이 뭔가 over engineering 느낌을 계속 주고있었다.
위 2번에 대한 내용을 몇번씩 돌려보면서 이러한 생각을 정리했다. Sasa는 그래서 Business의 행위(behavior)을 정의한 Interface와, 실제 동작을 구현한 Core로만 나누자고 제안을 한 것이다.
다만 여기서 Interface는 객체지향에서의 interface가 아니라 ‘Core의 기능을 외부에 노출시키는 역할’을 하는 것으로써 Sasa는 외부를 User나 REST, GraphQL Client정도만 이야기했는데, MVC의 Controller와 View에 해당할거같고 다른 건 무엇이 있을지는 아직 잘 모르겠다.
흥미로운건 양파 아키텍처에서는 Web Endpint와 DB 커넥션 둘다 외부와 소통하는 부분이므로 묶어서 infra layer로 취급했는데, 여기서는 Web은 Interface로 놓고 DB는 Business layer에 함께 묶었다는 것이다.
그러면 양파 아키텍처와는 어떤 차이가 생길까? 일단 Controller에선 Database에 대해 알 필요가 없어진다. 양파 아키텍처, 함수형 아키텍처를 하려면, 사이드 이펙트는 가장 바깥 레이어에서 주입해야하기 때문에 database를 컨텍스트 레이어에서 제외해야하나? 라는 고민이 계속 생긴다.
아니면 ApplicationServer 레이어와 DomainService 레이어를 나누고 ApplicationService레이어에서 데이터베이스 호출을 맡고, DomainService 레이어에서는 여러 Domain Model을 조합하는 역할, 즉 side effect가 없는 레이어로 유지해야 하나? 그러면 컨텍스트는 그냥 ApplicationService 레이어를 조합하는 역할? 이러면 레이어가 벌써 2개 3개 되는데 이게 맞는가? 에 대한 고민이 생기는 것이다.
함수형 아키텍처에 집착을 내려놓으면 이런 고민들을 뒤로 미뤄두고 일단 당장의 비즈니스 로직에 초점을 맞춰 빠르게 구현을 할 수 있다.
컨텍스트 레이어에서 디비 호출, 메일 전송 등을 포함한 비즈니스 로직을 전부 구현한다. 컨트롤러에서는 파라미터 정제, 응답 에러 파싱 등을 담당한다. 비즈니스 로직이 많아지고 컨텍스트가 점점 커진다 싶으면 쪼개야할 시점.
여기서 나의 한가지 고민은 컨텍스트를 어떻게 나눌까 하는 부분. 컨텍스트를 처음에 너무 작게 잡으면, 컨텍스트끼리 호출이 일어난다. 이러면 순환 참조가 생기게 되버린다. 컨텍스트 간의 참조를 아예 없애버려야 할까? 아니면 컨텍스트 상위 계층을 만들어서 통합해야할까?
일단 이 디자인은 처음 시스템 설계할때는 복잡성을 줄여주므로 매우 편하긴 한데, 프로젝트가 커졌을때 똑같이 적용될 수 있는지는 아직 잘 모르겠다. 비즈니스 로직이 복잡해지고 이벤트 소싱 아키텍처를 적용한다거나 하다못해 여러 GenServer(프로세스)를 이용하게 한다거나 할 때도 똑같이 적용할 수 있을까?
시스템의 각 서브도메인을 얼마나 잘 쪼개느냐가 관건일 수도 있는데, 그건 DDD같은 다른 디자인에서도 마찬가지의 문제고, 이 디자인을 적용했을 때 나눴다가 합쳤다가 하는 과정이 큰 고통없이 된다면 좋을 것 같다.
여담으로, Sasa는 이러한 자신의 생각도 맹목적으로 받아들이면 안되고, 자신의 문맥에 맞게 자신이 디자인 결정을 내려야 한다고 말한다.
Note
아래는 영상을 보면서 대충 정리한 내용들
예제, 코드 가독성
아래 코드를 가정해보자
def run(x) do
1
|> List.duplicate(x)
|> Integer.undigits()
|> Stream.iterate(fn previous ->
prefix =
(previous + 1)
|> Integer.digits()
|> List.insert_at(0, 0)
|> Stream.chunk_every(2, 1, :discard)
|> Stream.take_while(fn [a, b] -> a <= b end)
|> Enum.map(fn [_, b] -> b end)
suffix =
prefix
|> List.last()
|> List.duplicate(length(Integer.digits(previous + 1)) - length(prefix))
Integer.undigits(prefix ++ suffix)
end)
|> Stream.take_while(&(&1 < Integer.pow(10, x)))
|> Enum.count()
end
하지만 다음 코드는 일단 바로 목적을 이해할 수 있다.
def run(length),
do: Enum.count(valid_passwords(length))
그 다음 세부 로직들이 나온다.
.. 중략 ..
나중에 이 코드가 성능에 문제가 있음을 알았고, 더 빠른 코드로 바꿀 수 있었다.
def run(length) do
for _step <- 1..(length - 1)//1,
digit <- 9..1,
reduce: {9, 8, 7, 6, 5, 4, 3, 2, 1} do
counts ->
carry = if digit == 9, do: 0, else: elem(counts, digit)
count = elem(counts, digit - 1) + carry
put_elem(counts, digit - 1, count)
end
|> elem(0)
end
이렇게 바꿀 수 있었던 이유는, 가독성 있는 코드를 앞에서 짰기 때문에. 그 코드를 다시 읽고 이해하고 더 나은 코드로 만들 수 있었다.
가독성이 좋은 코드는 가진 기능을 명확히 설명하고, 성능상의 이유로 리팩토링 할때도 매우 편함
용어에 관해
발표자는 요즘 readability, maintainability, quality, simplicity라는 용어 대신에 clarity라는 용어를 사용한다.
Clarity는 뭐가 다른가? 위의 용어들은 때론 모호해서 소모적인 논쟁들을 발생시킨다. 명료성은 내가 어떤 코드를 봤을때 그것이 무슨 문제를 해결하려 하고 있고 어떤 해결책을 제시하고 있는가를 알 수 있냐는 것을 의미한다.
코드 리뷰에 관해
코드리뷰 매우 중요. 각각의 관점에서 지켜야할 원칙들을 보자
Author
- 커밋, PR은 최대한 쪼개야 한다.
- 명확한 코드
Reviewer
- 모르면 부끄러워하지 말고 물어봐야한다.
Code
코드에 대해 얘기해보자. 코드가 어떻게 명확성을 가질까
Seprate Of Concern
1974년 다익스트라 논문에 처음 등장. 영역을 나눠서 독립적으로 추상화시키는? 코드레벨에도 적용 가능
만약에 항상 함께 생각해야하는 두 부분을 나눠서 설계한다면, 괜히 추가적인 비용을 지불하는 셈이다.
- Interface
- Core
Interface는 core layer를 외부 유저나 클라이언트에 노출시키는 레이어, 여러 delivery mechanism 가능
이렇게 나눈 이유는, 인터페이스는 코어 레이어에 상관없이 설계할 수 있고, 그 반대도 마찬가지이기 때문
간단한 구체적 예를 생각해보자.
- 유저를 이메일과 비밀번호를 받아서 등록
- 유저는 로그인할수 있어야 한다.
defmodule MySystem do
def register(...)
def login(...)
end
core function으로 들어올 argument는 well structured shape이다. 즉 key들은 atom이어야하고, 그냥 아무렇게나 주어진 map이 아니어야 한다. optional도 표현되어야함
@spec register(%{
email: String.t(),
password: String.t(),
date_of_birth: Date.t() | nil,
# ...
}) :: # ...
def register(params)
typespec을 쓰면 명확성을 나타내기 좋음
다음은 컨트롤러 함수이다.
def register(conn, params) do
schema = [
email: {:string, required: true},
password: {:string, required: true}
date_of_birth: :datetime,
# ...
]
with {:ok, params} <- normalize(params, schema),
{:ok, user} <- MySystem.register(params) do
# respond success
else
{:error, reason} ->
# respond error
end
end
즉 params는 string이 key이고, 키가 빠져있을수도 있고, 등등. 이것을 core function에 넣기전에 정제(normalize)해야한다.
normalize는 validate와 다르다. structure화 하는 느낌.
여기서 두가지 관점 차이를 보여주고자 한다. 앞에서 보여준 컨트롤러 형태와, 피닉스의 컨텍스트 형태다. 피닉스 컨텍스트 사용 예제를 보면 주로 다음처럼 되어있다.
def register(conn, params) do
case MySystem.register(params) do
{:ok, user} -> #...
{:error, reason} -> #...
end
end
코드 수도 적고 생산성도 좋은 코드이긴 한데, clarity에 대한 비용을 지불한다고 볼 수 있다.
위의 컨텍스트 함수는 다음과 같은 형태를 보일 것이다.
@spec register(%{String.t() => any()}) :: #...
def register(params)
무슨 키가 넘어가는지 등등을 타입스펙에 명시할수가 없다. implementation detail을 볼수 밖에 없다는 소리
피닉스의 컨텍스트 개념도 SoC의 하나라 볼 수 있다. 그건 말하자면 피닉스를 처음 접하는 사람들을 위한 온보딩 개념이다. generator 이런것도 온보딩을 위한것들.
좀 더 코드를 딥하게 보자면, 다음 코드는 plain english로 바꾸었을때, 유저를 insert하기 위해 prepare하고, 그 다음 insert한다.
여기서 prepare 어쩌고 하는 부분이 불필요하게 복잡하다.
def register(params) do
%User{}
|> User.registration_changeset(params)
|> Repo.insert()
end
다음처럼 바꾸는게 더 명확하다고 생각한다.
def register(params) do
%User{}
|> change(%{
email: params.email,
password_hash: password_hash(params.password)
})
|> unique_constraint(:email)
|> # other validations
|> Repo.insert()
end
이렇게 그냥 풀어쓰고, 만약 여기서 추가적인 동작이 필요하다면, 다음처럼 split하기 시작한다.
def register(params) do
with {:ok, user} <- insert_user(params),
do: send_activation_email(user)
end
…and now there lower level concerns are naturally emerging driven by the actual requirements and the big point I want to make here is that like concerns are best chosen by looking at the actual material we’re dealing with, not by waving our hands through the air randomly and citing various random principles, you know, that’s going to be very counterproductive.
앞서 말한 아키텍처 레벨로 다시 돌아가서 2개 레이어를 다시 다음과 같이 표현해본다.
- Interface
- Core (biz + infra)
I’m calling it core because it’s not pure business slash domain, right? So we’re dealing with some business class domain things such as, you know, use cases flows, business domain or domain level rules and constrains and then we deal with infrastructure as well, which means that we have to interact with services that we’re using ourselves right to provide our own service, so for example, database most frequently, mailer service, notification service, payment gateway, …
..because the thing is that splitting business from the infrastructure is going to lead to more ceremony you know the similar I would say actually the larger amount than the initial split
business domain layer를 infra에서 분리하는건 더 많은 번거로움을 초래하기에, 처음부터 business랑 infra를 나누려고 하는건 일을 더 복잡하게 만든다고 생각. 양파 아키텍처, 클린 아키텍처 같은것들을 적용하는 것 말이다.
Business Behavior를 정의한 상대적으로 매우 얇은 레이어 Interface와 Infra에 지배(dominated)된 Core 레이어, 이렇게 2가지로 생각하면 좋을 것 같다.
이렇게 2개로 나눈걸 agency style 이라고 부르면 좋을것 같다.
Test
test에 디테일을 숨기는것 필요
Avoid testing implementation details, test behaviors
Test 부분은 무슨 말인지는 알겠는데, 일단 앞부분들을 제대로 소화하고나서, 다시 정리해서 실천해보기로 하자.