야생의 Jinja template을 리팩토링 하기
Geon Son — 손 건
@jen6
Airbridge에는 광고를 누른 유저가 앱 혹은 웹 페이지에 도착하기 전 리다이렉션 되면서 잠시 거치는 에어페이지가 있습니다. 유저의 입장에서는 잠깐 거쳐 가는 페이지이지만 내부적으로는 좋은 사용자 경험을 위해 많은 역할을 하고 있습니다.
•
고객사의 브랜딩을 표현할 수 있는 로고와 타이틀 노출
•
•
광고를 본 유저가 웹에서 앱으로 매끄럽게 이동하도록 딥링크 시도
에어페이지의 다양한 요구사항을 만족시키기 위해 템플릿 엔진인 Jinja를 사용하고 있습니다. Jinja는 HTML을 렌더링하는 시점에 데이터를 넣어주면 템플릿에 있는 특정 부분만 렌더링하거나 변수들을 넣어줬던 데이터 값으로 치환하는 라이브러리입니다.
이번 글에서는 Jinja template의 매크로 기능을 사용해 복잡한 템플릿과 렌더링 데이터를 적절한 단위로 나누는 리팩토링을 진행해보겠습니다.
에어페이지 예시
야생의 Jinja template 문제
야생은 다람쥐도 강하게 만듭니다
Jinja를 처음 사용할 때에는 템플릿에 필요한 데이터들만 넣어주고, 그 데이터를 escape, replace 하는 정도의 단순한 작업이 대부분입니다. 하지만 야생 같은 프로덕션 환경은 호락호락하지 않습니다. 요구사항에 맞춰 템플릿에 이것저것 기능을 붙이다 보면 템플릿을 렌더링하는데 필요한 데이터양이 많아지고 한 템플릿 파일 안에 여러 기능이 뒤죽박죽 섞이게 됩니다. 이렇게 야생에서 구른 Jinja template은 렌더링 데이터 중 어떤 변수가 쓰이는 것인지, 템플릿 안에서 사용하는 변수가 렌더링 컨텍스트에 제대로 선언이 돼 있는지, 데이터 타입은 맞는지 등 파일 수정 시 고려해야 할 점이 많아진다는 걸 사용해보신 분들이라면 대부분 경험해 보셨을 겁니다.
// jinja에서 렌더링 하기 위한 context (python code)
context = {
"app": app_info,
"request_url": request_url,
"redirect_url": redirect_url,
"short_id": short_id,
"short_url": short_url,
"device": client.device,
# ... 약 30개가 넘는 context data
}
http_response = await render_template(
request=self.request_object.request,
file=target_template,
**context,
)
------------------------------------
//jinja template file
<section>
<main id="{{page_type}}" class="main">
<div class="container">
<a class="logo-block" href="{{app.web_landing}}" target="_blank" onclick="trackOutbound('{{app.web_landing}}');">
<div class="logo">
<img src="{{app.app_icon_image_url}}" alt="{{app.app_title}}">
</div>
<h2 class="logo-label">{{app.app_title}}</h2>
</a>
</div>
<!-- ... 본문 생략 ..생략 ... -->
</section>
<!-- Airbridge WEB SDK -->
<script>
airbridge.init({
'app': '{{app.app_subdomain}}',
'appToken': '{{app.app_token}}',
'simplelinkId': '{{short_id}}',
{% if deeplink_enabled %}
'stats': false,
{% endif %}
'airpage': true
})
</script>
JavaScript
복사
위 코드 예시는 에어페이지를 렌더링 하기위한 데이터가 담겨있는 python code 일부와 Jinja template 일부를 가져왔습니다. 위에서 설명한 야생의 Jinja template 특징과 같다는 걸 볼 수 있습니다.
이렇게 여러 동작을 하는 템플릿을 리팩토링 하다 보면 넘겨줘야 할 데이터를 빠뜨리거나, 코드 리딩시 어느 부분을 봐야 할지 찾아보느라 많은 시간을 소모하게 됩니다. 에어페이지를 개발하면서 이런 문제들이 반복되었고 더는 버틸 수 없다고 생각하며 리팩토링을 결심하게 됩니다.
리팩토링 1. 템플릿을 적절한 단위로 나누기
모든 코드를 리팩토링 할 때 기본은 코드의 응집성을 고려해 적절한 단위로 나누는 것입니다. 코드의 응집성이란 관련된 코드와 데이터를 묶는 것을 뜻합니다. 관련된 코드와 데이터를 묶으면 한 기능을 변경할 때 묶은 단위 이외의 코드들을 최소한으로 수정할 수 있어 유지 보수할 때 비교적 쉽게 작업할 수 있고 코드를 읽을 때도 인지부하를 줄일 수 있는 장점이 있습니다.
여러 동작을 하는 템플릿을 나누기 위해 Jinja template의 Macro를 사용했습니다. Macro는 여러 프로그래밍 언어에 있는 함수와 비슷한 형태로 템플릿의 호출 시 템플릿의 일부분을 렌더링하고, 이때 인자를 넘겨받아 사용할 수 있습니다.
위 템플릿 예시에서 코드의 응집도를 고려해 크게 두 부분으로 나눌 수 있습니다.
1.
에어페이지의 앱 정보 렌더링
2.
에어브릿지 서비스의 Web SDK JavaScript init 함수에 들어갈 옵션 렌더링
Web SDK 템플릿을 sdkInit macro로 분리한 후 전체 렌더링 하는데에서 호출하도록 변경합니다. 간단하지만 이렇게 macro를 써서 나누기만 해도 템플릿의 복잡성을 줄일 수 있고, 템플릿의 각 부분이 어떤 역할을 하는지를 쉽게 전달할 수 있게 됐습니다.
// web_sdk/sdk.html
{% macro sdkInit() -%}
airbridge.init({
'app': '{{app.app_subdomain}}',
'appToken': '{{app.app_token}}',
'simplelinkId': '{{short_id}}',
{% if deeplink_enabled %}
'stats': false,
{% endif %}
'airpage': true
})
{%- endmacro %}
// main.html
{% import "web_sdk/sdk.html" as sdk %}
<section>
<main id="{{page_type}}" class="main">
<div class="container">
<a class="logo-block" href="{{app.web_landing}}" target="_blank" onclick="trackOutbound('{{app.web_landing}}');">
<div class="logo">
<img src="{{app.app_icon_image_url}}" alt="{{app.app_title}}">
</div>
<h2 class="logo-label">{{app.app_title}}</h2>
</a>
</div>
<!-- ... 본문 생략 ..생략 ... -->
</section>
<script>
{{ sdk.sdkInit() }}
</script>
TypeScript
복사
리팩토링 2. 각 매크로에서 쓰는 변수들을 묶어주기
템플릿의 가독성은 많이 좋아졌지만, 여전히 렌더링하는 시점에 context로 넘겨주는 변수가 많아 각 변수가 어떤 템플릿에서 쓰이고 있는지 알기 어려운 상태입니다. 예를 들면 A라는 변수가 중계 페이지의 앱 정보를 렌더링하는 데만 쓰이는 줄 알고 변수의 값을 변경했는데 A 변수를 Web SDK에 옵션 전달하는 부분에서도 쓰고 있었다면? 상상하기 싫은 대참사가 일어나게 될 겁니다. 이런 문제를 사전에 예방하기 위해 각 매크로에서 쓰는 변수들을 dataclass를 이용해 묶어주기로 했습니다.
dataclass를 사용하면 여러 장점이 있습니다. 이전에는 dataclass 대신 dict에 모든 값을 넣었기 때문에 이 변수가 무슨 타입인지, 무슨 값인지를 확인하기가 어려웠습니다. 이를 확인하기 위해서는 해당 변수에 대입하는 모든 코드를 찾아봤어야 했고 이것 또한 피곤한 일이었습니다. dataclass를 사용하게 되면서 어떤 Object가 어떤 부분에서 사용되는지를 모두 명시해 놓기 때문에 템플릿에서 변수들의 사용 여부를 파악하기가 한층 쉬워졌습니다.
@dataclass
class SdkContext():
app: AppInfo
deeplink_enabled: bool
short_id: str
Python
복사
macro에서 쓰는 변수들을 dataclass로 묶어준 것을 managed context, 아직 macro로 바꿔주지 못한 기존 레거시 부분을 unmanaged context로 명명했습니다. 이렇게 구분한 이유는 다음 글에서 작성할 테스트 때문도 있지만 리팩토링을 진행하다 보면 과도기적으로 리팩토링이 된 부분과 안된 레거시가 공존할 수 있기 때문입니다. managed context는 macro의 첫 번째 인자로 해당 dataclass를 넘겨주고 macro 안에서는 전역 context로 넘어간 unmanaged context를 사용하지 않고 managed context만 사용합니다.
// web_sdk/sdk.html (managed context)
{% macro sdkInit(sdk_context) -%}
airbridge.init({
'app': '{{sdk_context.app.app_subdomain}}',
'appToken': '{{sdk_context.app.app_token}}',
'simplelinkId': '{{sdk_context.short_id}}',
{% if sdk_context.deeplink_enabled %}
'stats': false,
{% endif %}
'airpage': true
})
{%- endmacro %}
// main.html (unmanaged context)
{% import "web_sdk/sdk.html" as sdk %}
<section>
<main id="{{page_type}}" class="main">
<div class="container">
<a class="logo-block" href="{{app.web_landing}}" target="_blank" onclick="trackOutbound('{{app.web_landing}}');">
<div class="logo">
<img src="{{app.app_icon_image_url}}" alt="{{app.app_title}}">
</div>
<h2 class="logo-label">{{app.app_title}}</h2>
</a>
</div>
<!-- ... 본문 생략 ..생략 ... -->
</section>
<script>
{{ sdk.sdkInit(sdk_context) }}
</script>
TypeScript
복사
기존의 한 개의 템플릿에 있던 코드들을 관련성 있는 템플릿 코드와 데이터를 묶어 여섯 개의 템플릿 파일과 managed context로 분리해서 코드의 응집성을 높였습니다. 응집성이 높아지면서 가독성과 변수 사용 여부를 파악하기 쉬워져 코드 유지보수 측면에서 이전과 비교하면 많이 편해졌습니다.
에필로그
이번 글에서는 Jinja 템플릿의 리팩토링 방향성에 대해 간단히 얘기해봤습니다. 사실 이런 글을 읽어도 적용하면서 많은 시행착오를 거칠 겁니다. 기존에 템플릿 렌더링 테스트가 촘촘하게 돼 있지 않으면 리팩토링을 진행하면서 실수하거나 팀 전체가 계속 managed context를 사용하도록 만드는 것과 같은 힘든 부분들이 많습니다.
예를 들자면:
•
기존 템플릿에 변수들이 많이 있다면 새로운 클래스에 옮기면서 변수를 빠뜨린다면 렌더링 시 해당 부분이 안 보이는 문제가 생길 수도 있습니다. 템플릿에서 context에 없는 변수를 사용하더라도 에러가 발생하지 않고 빈 값으로 렌더링 되기 때문입니다.
•
열심히 managed context로 분리해도 다른 사람이 코딩할 때 managed context에 추가하지 않고 기존 방법인 unmanaged context에 그냥 변수를 추가해버리는 일이 생길수도 있습니다.
다음 글에서는 이런 문제들로부터 저를 여러 번 구해준 Jinja template의 AST 분석을 사용해 빠뜨린 변수 없이그리고 managed context 외의 다른 unmanaged context를 사용하지 않도록 방지하는 테스트를 작성해보도록 하겠습니다.
ᴡʀɪᴛᴇʀ
Geon Son @jen6
Backend Software Engineer @AB180